This is the full developer documentation for Cratis # Software gets built differently now > In the age of AI, software gets built differently. Cratis is one connected, purposeful ecosystem — an event store, an application framework, a UI library, developer tooling, and AI skills — designed together, from one company, so the pieces fit instead of being assembled. It is database-agnostic (MongoDB by default, PostgreSQL, SQL Server, or SQLite) with clients for .NET, TypeScript, Kotlin/Java, and Elixir. The open-source Cratis products are MIT licensed and free to use; Studio is a paid product in Preview. One Ecosystem Event Sourcing CQRS Application Framework The Stack Any Language Any Database AI-Native Studio Screenplay Prologue ## Cratis facts Cratis is an open-source (MIT) event sourcing and CQRS platform for .NET, built around the Chronicle event-sourcing database. The facts below are the short, citable version; [The Cratis Stack](/cratis-stack/#capability-map) has the full product table. * **Products.** Chronicle is the event store and runtime. Arc is the CQRS application framework with generated TypeScript proxies. Components is a React UI library for Arc. Fundamentals holds the shared building blocks, and Specifications provides BDD-style testing. The CLI, Workbench, and Chronicle MCP server inspect and operate a running Chronicle. Cratis AI ships rules and skills for AI agents. * **Model-first layer.** Screenplay, Stage, and Prologue are experimental. Studio is a paid product in Preview. * **Licence.** The open-source Cratis products are MIT licensed. Studio is paid. * **Languages.** Chronicle has a first-class .NET client and clients for TypeScript, Kotlin/Java, and Elixir, plus an experimental Go client. A Python client is in development and not yet published. Arc runs on ASP.NET Core and Spring Boot, and Components targets React. * **Storage.** Chronicle stores events in MongoDB (default), PostgreSQL, Microsoft SQL Server, or SQLite. * **Downloads.** over 2.5 million downloads across NuGet, npm, Docker Hub and Hex, counted October 2026. Chronicle, including its predecessors Aksio Cratis and the Dolittle Runtime: over 1.5 million downloads. Arc, including Aksio Cratis Applications and the Dolittle SDK: over 1.2 million downloads. * **Where to get it.** NuGet, npm, Maven Central, Hex, Docker Hub, and [GitHub](https://github.com/Cratis). See the [support matrix](/compatibility/#support-matrix) for what each combination supports. * **Who builds it.** Cratis, a small company that builds and maintains the platform. ## Start here New to Cratis? Pick the route that matches what you want to do first: [Build your first full-stack feature ](/build-a-full-app/)Scaffold an app from the Cratis template, then add one slice from command to event to live React screen. [Choose where to start ](/adopting-cratis/)New system or existing one, .NET or another language: pick the product to adopt first. [Follow a learning path ](/learning-paths/)An ordered reading route for event-sourcing beginners, builders, evaluators, operators, and frontend developers. ## Why this matters Most stacks are assembled: a database here, a framework there, a UI kit from somewhere else, and glue code everywhere in between. Every seam is something a team — and now every AI assistant — has to learn, maintain, and keep in sync. Cratis takes the opposite bet. One company designs the whole path — from the facts your system records, through the application that shapes them, to the screens people use and the tools that operate it — around one shared model and one set of conventions. We believe software built this way is better, more robust, faster to deliver, higher quality, and more cost-effective, because the seams you would normally hand-write and babysit simply are not there. That same coherence is what makes the ecosystem AI-ready: conventions that are explicit and consistent are conventions an AI agent can follow. Cratis ships its conventions as [AI skills, rules, and diagnostics](/ai/), so assistants build with the grain of the platform instead of guessing at it. ## One connected ecosystem — adopt it one piece at a time Designed together does not mean all-or-nothing. Each product stands on its own, and you choose what you need: run Chronicle alone as your event store, build with Arc without event sourcing at all, or take the whole stack and let value flow through the entire chain. Start with the piece that solves today’s problem — the rest are there when you want them, not before: [Chronicle — the event store ](/chronicle/)Record what happened as immutable facts and derive read models from them. Database-agnostic — MongoDB (default), PostgreSQL, SQL Server, or SQLite — so it runs on the database you already operate. [Arc — the application framework ](/arc/)Turn commands and queries into a full-stack CQRS application with validation, authorization, and generated TypeScript proxies — no hand-written API layer. [Components — the UI library ](/components/)React components that render Arc's generated contracts as forms, dialogs, and live tables, so screens follow the model. [CLI and Workbench — the tooling ](/cli/)See what the running system is doing — inspect events, observers, and read models, replay, and diagnose — from the terminal or the browser. [AI skills — the accelerant ](/ai/)The same conventions developers use, packaged for AI agents: Cratis AI rules and skills, analyzers, the CLI command catalog, and an MCP server into the running store. [Clients for your language ](/chronicle-clients/)Use Chronicle from .NET (first-class), TypeScript, Kotlin/Java (JVM), Elixir, or Go (experimental) — with a Python client coming soon. ## Built to deliver outcomes, not just features Cratis is opinionated on purpose. The opinions are what carry the value: * **Built to keep history you can trust.** Events are immutable facts, and read models derive from them — audit and replay are part of the design, not an afterthought. * **Built to remove glue.** One C# model becomes typed frontend contracts; drift between backend and frontend becomes a build error instead of a production incident. * **Built to fit your infrastructure.** A database-agnostic event store and multiple language clients mean the ecosystem adapts to you — not the other way around. * **Built to be adopted piecemeal.** Every product is independently adoptable. Chronicle works without Arc, Arc works without event sourcing, Components is optional on top of Arc — you never buy the whole stack to get one piece, and nothing is wasted when you later add the next one. * **Built for people and AI together.** Predictable, convention-driven codebases are easier for new developers to join and for AI agents to extend. We believe this adds up to software that is more robust, faster to build, and more cost-effective to own. [Why developers choose Cratis](/why-cratis/) walks through the reasoning; [The Cratis Stack](/cratis-stack/) shows the whole journey end to end. ## Use one piece — or the whole thing Modularity is a design commitment, not a marketing line. The products compose, but none of them holds the others hostage — each seam below is an intentional, supported adoption boundary: * Chronicle stands alone. Use it purely as an event store — from .NET, TypeScript, Kotlin/Java, or Elixir — with no Arc, no Components, and no other Cratis product involved. * Arc does not require event sourcing. Use application services, MongoDB, or Entity Framework Core for current-state behavior, and add Chronicle only where an event-sourced path fits. * Arc does not require Components. Generated TypeScript clients can be consumed directly or through Arc’s React packages. * Chronicle is not .NET-only. The [client landing page](/chronicle-clients/) maps each shipped client to its documentation and packages; the protocol details live in the [Chronicle architecture](/chronicle/architecture/) pages. * Chronicle and its bundled local Workbench are available as MIT-licensed self-hosted software; authorized local use is separate from paid Cratis support, hosted coordination, or managed operational responsibility. These boundaries do not establish compatibility with every package, provider, browser, runtime, topology, or product version. Check the documentation and package manifests for the combination you use. ## Go deeper Technical depth lives in each product’s own documentation: [Start Chronicle locally ](/chronicle/get-started/)Run the development image, wait for readiness, and open the bundled Workbench. [Understand Chronicle architecture ](/chronicle/architecture/)Locate protocol surfaces, the kernel, event stores, namespaces, processing, and subscriptions. [Run Chronicle on your database ](/chronicle/hosting/configuration/storage/)Configure the storage provider — MongoDB (default), PostgreSQL, SQL Server, or SQLite. [Pick a Chronicle client ](/chronicle-clients/)Start from .NET, TypeScript, Kotlin/Java, Elixir, or Go (experimental), with links to each client's documentation and packages. [Explore Arc ](/arc/)Choose commands, queries, observable-query APIs, frontend contracts, identity, tenancy, persistence, or testing. [Browse Components ](/components/)Find package setup, component areas, styling boundaries, examples, and migration guidance. [Use the CLI ](/cli/)Install the CLI, connect to Chronicle, inspect events and read models, and diagnose observer failures. [Run a sample ](/samples/)Use Cratis-owned sample applications to evaluate one bounded technical path. ## About this site Product repositories own their technical documentation, examples, packages, and released contracts. This site aggregates that material into one searchable set of tutorials, guides, explanations, and references. * [Browse the Cratis repositories](https://github.com/Cratis) * [Join the Cratis community](https://discord.gg/kt4AMpV8WV) to ask questions and get help from the team * [Report a documentation problem](https://github.com/Cratis/Documentation/issues) * [Read company, fit, and trust information](https://cratis.no/) # 404 > That page wandered off. Let's get you back to something useful. Looking for something specific? Try the **search** in the top bar, or jump to a product: * [Chronicle](/chronicle/) — the event sourcing platform * [Arc](/arc/) — the full-stack application framework * [Components](/components/) — the React component library # Adopting Cratis > Where to start with Cratis — default to the full event-sourced stack for new information systems, or adopt one product at a time in existing systems. Getting oriented ## Where to start Cratis products stand on their own and compose when you want them to. For a new information system, our default is the full loop: Chronicle for event sourcing, Arc for CQRS, Components for React, and AuthProxy at the edge. For an existing system or a bounded current-state slice, you can adopt one piece at a time without losing the path back to the full stack. ## Pick your entry point Three things decide where you start: **what you’re building on** — a new codebase or an existing one — **which boundary you are changing first**, and **which language your services are written in**. Event sourcing is the default architecture we recommend for information systems; CQRS, generated contracts, and edge identity can still be adopted independently. ``` New a corner where current state is genuinely enough Existing Frontend/backend glue Audit & history New information system or existing? Default stack: Arc + Chronicle + Components Arc + Components over MongoDB / EF Core What hurts most? Add Arc to your app Introduce Chronicle, one slice at a time ``` | Your situation | Start with | Why | | --- | --- | --- | | New information system | **Arc + Chronicle + Components** | The default Cratis loop: CQRS at the boundary, event history underneath, React generated from C# — the [full-stack capstone](/build-a-full-app/) builds the shape. | | Bounded slice where current state is genuinely enough | **Arc + Components** over a database | Typed commands, queries, and a generated React client with CQRS but no event log — [CQRS without event sourcing](/arc/arc-without-event-sourcing/). | | Existing .NET API, frontend glue is the pain | **Arc**, added to your app | A typed TS client for new endpoints, without rewriting what you have. | | Existing system, you need history or audit | **Chronicle**, incrementally | Event-source one bounded context; leave the rest as it is. | | New full-stack app on Kotlin or Java | **Arc + Chronicle** on Spring Boot | The [Cratis Kotlin](/templates/cratis-kotlin/) and [Cratis Java](/templates/cratis-java/) templates scaffold the same full-stack shape with a React frontend. | | Services in TypeScript, Kotlin/Java, or Elixir that need an event store | **Chronicle** with your language’s client | Chronicle’s clients talk to the same kernel over gRPC — see [Chronicle in your language](/chronicle-clients/). | | Need to separate CQRS from event sourcing | Read first | [CQRS without event sourcing](/arc/arc-without-event-sourcing/), then [Why developers choose Cratis](/why-cratis/). | ## Starting from scratch (greenfield) A new project is the easy case — nothing to migrate, no constraints. For an information system, start with the whole loop unless you have a clear reason not to: * **Default to the full experience.** Follow [Build a full-stack feature](/build-a-full-app/): it scaffolds the application from the [Cratis templates](/templates/getting-started/) and adds one slice by hand. You’ll have the command → event → projection → query → React loop running, with history available from the event log. The template wires Arc and Chronicle into one host through the [Cratis package](/arc/backend/csharp/chronicle/cratis-package/)’s `AddCratis`/`UseCratis`. * **Use Arc-only for deliberate current-state slices.** If a bounded part of the app is just reference data, settings, or an adoption step, [Arc over a database](/arc/arc-without-event-sourcing/) still gives you CQRS, generated proxies, and live queries. Adopting Chronicle later is mostly a write-side change: the query contract and the React screens that use it can stay the same, though the query’s implementation moves to the read model a projection builds. CQRS and event sourcing are independent Arc gives you CQRS: commands for information entering the system, queries for information leaving it. Chronicle gives you event sourcing: facts as the durable source of truth. They fit together extremely well, but either one can stand without the other. ## Adding to an existing system (brownfield) You rarely get to start clean. Cratis is built to be adopted a slice at a time, alongside code that already works — you don’t rewrite, you grow into it. ### Add Arc to an existing ASP.NET Core app If your pain is the **frontend-to-backend boundary** — hand-written controllers, DTOs duplicated in TypeScript, fetch wrappers that drift out of sync — Arc slots into an app you already have. Register it on your existing host and start expressing *new* endpoints as commands and queries; Arc generates their typed proxies while your existing controllers keep running untouched. [Set up a standalone Arc backend](/arc/backend/csharp/getting-started/) shows the packages and the proxy-generation settings, with no Chronicle or template involved. Arc’s persistence meets your data where it already lives: its [MongoDB](/arc/backend/csharp/mongodb/) and [Entity Framework](/arc/backend/csharp/entity-framework/) integrations read and write the database you already use — EF Core even has a [direct-registration path](/arc/backend/csharp/entity-framework/getting-started/) designed for slotting into an existing application without taking over the whole framework. For controllers and MediatR handlers, the [MediatR, MVC, and Arc](/arc/backend/csharp/coming-from-mediatr-and-mvc/) bridge maps familiar concepts onto Arc’s model. ### Introduce Chronicle into an existing domain If your pain is **history** — you need an audit trail, a “how did this order get into this state?” view, or a new read model the current schema can’t serve — you don’t have to event-source the whole system in one move. Pick the bounded context where the model is clearest and event-source that first: 1. Model the facts for that area as [events](/chronicle/concepts/event/), and append them when the corresponding things happen — for new behavior, append the event; for existing writes, append alongside the current write. Existing rows do not become events on their own; decide whether you need historical events at all, and if so, write them deliberately. 2. Build [projections](/chronicle/projections/) that fold those events into exactly the read models that context needs. They can sit right next to your existing tables. 3. Let the rest of the system keep working as it is. Event sourcing earns its place one context at a time, not as a big-bang rewrite. The [CRUD, EF Core, and Chronicle](/chronicle/coming-from-crud/) guide maps tables and `SaveChanges` onto events — that’s the mental shift that makes this click. Writing to two places Appending an event alongside an existing write is a dual write: the two can succeed or fail independently. Decide which one is the source of truth for that context, make the other derived from it where you can, and plan how you detect and reconcile a mismatch. The [CRUD, EF Core, and Chronicle](/chronicle/coming-from-crud/) guide maps the concepts. Keep the boundary honest Event sourcing is the default for information systems, not a mandate that every settings table needs a stream. Use Chronicle for the facts and processes that define the domain, and leave genuinely current-state-only corners on Arc-over-a-database. [When to use event sourcing](/chronicle/concepts/when-to-use-event-sourcing/) is the honest filter. ## Grow into the full stack However you start, the pieces are designed to be *added*, not swapped out. The common growth paths: * **Arc-over-a-database → add Chronicle.** Move a slice’s write side from a direct insert to appending an event with a projection behind it. The query’s contract and the React that calls it can stay the same, while the query now reads the projected read model; see the side-by-side in [CQRS without event sourcing](/arc/arc-without-event-sourcing/). * **Chronicle-only service → add Arc and a frontend.** Already event-sourcing from a worker or service? Put Arc in front to expose typed commands and queries, and Components to render them — without changing how your events are stored. * **Any backend → add Components.** The React library consumes Arc’s generated proxies, so adding it is a frontend-only step. ## Next steps [ Why developers choose Cratis](/why-cratis/) [How the three products stand alone and compose — the map this page navigates.](/why-cratis/) [ CQRS without event sourcing](/arc/arc-without-event-sourcing/) [The standalone Arc shape, and the write-side change that moves a slice to Chronicle.](/arc/arc-without-event-sourcing/) [ Build the full loop](/build-a-full-app/) [The full-stack capstone — command to event to live React screen, with history.](/build-a-full-app/) # Agentic development with event sourcing: a guide > Why event-sourced systems give AI coding agents verifiable history, explicit contracts and replayable tests, and how to set it up in practice. Agentic development with event sourcing means letting AI coding agents design, change and operate a system whose state is an append-only log of named events, whose behavior is expressed as commands, and whose rules are written as executable Given/When/Then specifications. The agent reads and writes the same explicit contracts a human reviewer reads. The log records what happened, and the specifications state what must stay true. Neither depends on what the agent remembers or guesses. This page explains why that combination helps, which Cratis tools implement each part, how they fit together, and what is still experimental. It is written for tech leads who are adopting coding agents and worry about correctness and auditability. Cratis is built by a team that works this way, and the tooling described here is the tooling we use. Where something is not finished, the page says so. Maturity at a glance The Chronicle MCP server, the CLI, Chronicle and Cratis Specifications are released. Cratis AI (rules and skills) and Studio are in Preview. Screenplay, Stage and Prologue are experimental. The [Cratis Stack](/cratis-stack/) page has a capability map with maturity labels. ## Why agents fail on CRUD systems A coding agent works from what it can see: the files in the repository, the prompt, and whatever it can query. Three properties of a typical create-read-update-delete (CRUD) system work against it. * **Hidden state.** The current row is the only truth. The agent cannot tell why a value is what it is, which code path last wrote it, or which of three similar columns the business actually relies on. * **No history.** An update overwrites the previous value. When an agent’s change corrupts data, nothing in the database says what the data looked like before, so a fix is a guess and an audit is a reconstruction from logs. * **Implicit contracts.** Behavior lives inside service methods, triggers and ORM hooks. An agent that changes one path cannot see which other behavior depended on it, and neither can the reviewer reading the diff. None of this is a defect in the agent. A human who joins a CRUD system cold has the same problems and solves them by asking colleagues. An agent cannot ask, so it fills the gap with the most plausible answer, and a plausible wrong answer is the dangerous one. Event sourcing removes each of these. State is the history of events, so nothing is hidden and nothing is overwritten. Every change is a named command with a typed shape, so the contract is explicit. The rest of this page builds on that. If you are new to the idea, [what is event sourcing](/concepts/event-sourcing/) covers the basics, and [coming from CRUD](/chronicle/coming-from-crud/) maps the concepts you already use. ## Events, commands and specifications as a contract agents can read Three artifacts carry the contract in a Cratis application. Each is plain code in the repository, so an agent reads it the same way it reads any other source file. | Artifact | What it states | Why it helps an agent | | --- | --- | --- | | **Command** | One intention a caller can express, with its inputs, validation and the events it may record | The agent finds every way the system can change, named and typed, in one place | | **Event** | A fact that happened, named in the past tense (`BookBorrowed`), immutable | The agent sees what the system records and cannot silently rewrite it | | **Specification** | A Given/When/Then example of the behavior: facts that already happened, the action, the expected facts | The agent has an executable definition of “correct” that does not depend on its own reading of the code | Given/When/Then maps one to one onto event sourcing. The given is the events that already happened, the when is the command or event under test, and the then is the events, read models or side effects that result. That is why a specification reads in the vocabulary of the event model, and why a domain expert can check it without reading the implementation. [Testing with Cratis](/testing-with-cratis/) shows the mapping in C#, Kotlin, Java and TypeScript, and [Specifications](/specifications/) documents the `Establish()`, `Because()` and `Destroy()` lifecycle. Here is a real specification from the [Decisions that hold](/decisions-that-hold-walkthrough/) walkthrough. It checks a rule that is easy to get wrong: two callers borrow the last copy of a book at the same time. ```csharp public class when_the_last_copy_is_borrowed_concurrently : Specification { static readonly ISBN _isbn = new("978-0316389704"); readonly CommandScenario _scenario = new(); CommandResult _result; void Establish() { _scenario.Given.ForEventSource(_isbn).Events(new BookAddedToInventory("The Last Wish", AuthorId.New(), 1)); _scenario.AppendConcurrently(_isbn, new BookBorrowed(_isbn, "The Last Wish", MemberId.New(), DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(21))); } async Task Because() => _result = await _scenario.Execute(new BorrowBook(_isbn, MemberId.New())); void Destroy() => _scenario.Dispose(); [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); [Fact] void should_report_a_conflict() => _result.ShouldHaveValidationErrorBecauseOf(ValidationResultReason.ConcurrencyViolation); [Fact] void should_append_nothing() => _scenario.AppendedEvents.Count.ShouldEqual(0); } ``` The scenario seeds one copy, so the validator sees one available copy and passes. A competing `BookBorrowed` is appended after the command reads and before it commits. The command is refused with a concurrency violation and appends nothing. The spec runs in process, without a Chronicle server or a database, so an agent can run it after every change. An agent that changes `BorrowBook` has a precise target: keep this spec green. An agent that weakens the rule fails the spec, not a reviewer’s patience. ## Event models as the shared source for humans and AI Contracts are cheaper to agree on before the code exists. [Event modeling](/event-modeling/) lays a system out as a timeline of screens, commands, events and read models. Because each step is named and typed, a model is a description an agent can read, and a plan a human can review before any code is written. Cratis puts the model in a file. [Screenplay](/screenplay/) is a declarative `.play` language for one bounded context: its events, commands, queries, projections, screens and rules. [Studio](/studio/) is the collaborative environment for editing Screenplay models. [Stage](https://github.com/Cratis/Stage) compiles Screenplay, verifies the specifications declared in the model, and renders a reviewable Arc and Chronicle application on disk. The practical effect is a shared source. The model is what the team reviews. The specifications declared in the model are what Stage verifies. The generated application is what the agent starts from and extends. When the model changes, the discussion happens on the model, in terms the business understands, rather than in a diff of generated code. The [event model is also the test](/event-modeling/): one column of a model becomes the Given/When/Then rows of a specification, as the [testing guide](/testing-with-cratis/#the-event-model-becomes-the-spec) shows step by step. If you already use [EventModelers.ai](/eventmodelers-ai/), its Build Kits give an agent a model-to-code path for C#, Kotlin and Java. The page [EventModelers.ai](/eventmodelers-ai/) explains what a Build Kit does and how to pick one. ## Letting agents inspect the runtime: Chronicle MCP and the CLI A specification covers behavior you thought of. Production shows the rest. An agent that is asked “why is this order cancelled?” or “why is this projection behind?” should read the system, not infer from the code. Cratis gives the agent two documented routes into a running [Chronicle](/chronicle/) store. ### The Chronicle MCP server The [Chronicle MCP server](/chronicle-mcp/) speaks the [Model Context Protocol](https://modelcontextprotocol.io). It runs over stdio as the `cratis/chronicle-mcp` container image and connects to a running Chronicle server. Releases are also listed in the official MCP Registry as `io.github.Cratis/chronicle-mcp`. Because it talks to Chronicle directly, it works with every Chronicle client language: .NET, TypeScript, Kotlin/Java and Elixir. It exposes two kinds of tools: * **Operate-side tools** inspect a live store: `list_event_types`, `get_events`, `get_tail_sequence_number`, `list_observers`, `list_failed_partitions`, `list_jobs` and others. * **Design-time tools** turn a request into a reviewable proposal: describe an event type, build an event catalog, find event types nothing consumes, run an ad-hoc projection, and scaffold a read model. The design-time tools follow one rule: introspect the store before generating anything. Field names, types and event names come from the schema registered with Chronicle, not from the model’s memory. When a requested event type is not registered, the tool says so and does not invent a shape. The tool that most directly answers the audit question is `explain_causal_trace`. It reads the ordered events of one event source together with each event’s correlation id and causation chain, so the agent can say what happened and why. The documentation calls it the equivalent of `git blame` for state. A minimal configuration for an MCP host that runs the server through Docker looks like this (taken from the [Chronicle MCP getting started](/chronicle-mcp/getting-started/) guide): ```json { "servers": { "Chronicle": { "type": "stdio", "command": "docker", "args": [ "run", "-i", "--rm", "-eCratis__Chronicle__Mcp__ConnectionString=chronicle://host.docker.internal:35000", "cratis/chronicle-mcp" ] } } } ``` ### Read-only by default The server starts in the `ReadOnly` profile. In that profile the tools that change job state (`stop_job`, `resume_job` and `delete_job`) are not in the tool list and cannot be called. The `Mutation` profile exposes them, and it refuses to start unless the deployment names a principal, an expiry in the future and explicit grants for each store, namespace, operation and target. Operation wildcards are invalid, so a new mutating tool is never granted by accident. Chronicle stays the authority and applies its own authentication and authorization to every call. See [deployment profiles](/chronicle-mcp/deployment-profiles/). Local defaults Without configuration the server uses Chronicle’s development credentials and `localhost:35000`. Those defaults are for a local development store. Give the server real credentials before you point it at a shared store. ### The CLI The [Cratis CLI](/cli/) covers the same ground from a terminal and is the better fit for agents that run shell commands. Running `cratis init` in your project writes a `CHRONICLE.md` that lists every command the CLI can run, adds instruction files for the AI tools it detects, and adds a `chronicle-diagnose` slash command. The CLI can also replay an observer, which rebuilds a read model; read [replay rebuilds read models](#replay-rebuilds-read-models) before an agent runs it. Neither route changes the event log. Both read it and operate observers and jobs; job control and replay change read-side state, so they need care and, over MCP, an explicit policy. To change state, the system goes through commands and events, so the history stays honest. ## Rules and skills that teach agents the conventions A general-purpose agent dropped into a Cratis codebase invents handler classes, misses the vertical-slice layout and hand-writes the API client that proxy generation already produces. [Cratis AI](/ai/) is a shared corpus of rules, skills, prompts and subagents that carries the conventions to the agent. The main path installs the corpus into a repository with the CLI: ```bash cratis ai install ``` Run without options it asks what the repository needs. To script it, select the harnesses, profiles and languages explicitly: ```bash cratis ai install \ --harnesses claude,codex,copilot,cursor,opencode,pi \ --profiles cratis/application \ --languages csharp,typescript ``` The CLI installs the managed content under `.cratis/ai`, creates native adapters for each selected coding tool, and records hashes in `.cratis/ai.manifest.json`. It refuses to overwrite files you own, and `cratis ai status` shows what is installed and whether a newer revision is available. Two other channels exist: the `@cratis/pi` package for Pi, and marketplace plugins for Claude Code, Codex, GitHub Copilot and Cursor, which deliver skills only. [Trust and distribution](/ai/trust-and-distribution/) explains how each channel is versioned, and the [harness guide](/ai/harnesses/) has the command for each tool. Some of what the corpus contains, by name: * The `new-vertical-slice` prompt scaffolds a feature: command, events, projection, query, React and specs. * Skills such as `cratis-arc-command`, `cratis-chronicle-read-model` and `cratis-chronicle-projection` build one artifact by convention. * The `write-specs` prompt and the `cratis-code-review` skill cover a feature with BDD specs and review it against the project’s standards. Rules and skills do not prove a change is correct. They make the first attempt look like the rest of the codebase, so review and specifications spend their time on behavior instead of style. The [code-analysis rules](/code-analysis/) enforce the same framework contracts during the build, whoever wrote the code. Cratis AI is in Preview. One practical limit applies to every agent: a large profile such as `cratis/full` can exceed the budget some agents give to skill descriptions, so install the profiles a repository needs rather than everything. ## Guardrails: specifications, review and replay An agent that can write code quickly needs checks that are as fast and that do not depend on the agent’s judgment. A guardrail model for agent-written changes has four layers. Each one is a mechanism you can point at. | Layer | Mechanism | What it catches | | --- | --- | --- | | Build | [Code-analysis rules](/code-analysis/) and typed commands, events and concepts | Contract mistakes before anything runs | | Specifications | In-process Given/When/Then scenarios for commands, read models and reactors | A behavior change the spec did not expect | | Constraints | Rules Chronicle checks when an event is appended, such as uniqueness, and append conditions for decisions that depend on state | A change that is accepted by the code but violates a rule under concurrency | | Review | A human reads the model, the specs and the diff | Wrong intent that a green build cannot see | ### Specifications as the first gate Specifications give an agent a fast loop. It changes the code, runs the specs and reads the failure. Because specs are written in the language of events, a failing spec names the fact that was expected and the fact that happened. [Testing with Cratis](/testing-with-cratis/) lists the in-process scenarios for each side: command scenarios for the write side, read-model scenarios for projections and reducers, and reactor scenarios for side effects. The specs prove behavior, so the first review question for an agent-written change is whether the specs describe the behavior you want. An agent can write a spec that passes and asserts the wrong thing. Review the specification diff as carefully as the implementation diff, and prefer specifications written or approved from the event model. ### Constraints and decisions at append time Some rules cannot be checked by a unit spec alone, because they depend on what other callers do at the same moment. Chronicle checks constraints when an event is appended and rejects the append if the rule fails. For decisions that read state, it can check the state the decision was based on: if a matching event was written since, the append is refused. The [keeping decisions correct](/concepts/keeping-decisions-correct/) page explains the model. This matters for agents because the rule is enforced where the data is written, not only where the agent happened to remember it. ### Replay rebuilds read models The log also makes read models disposable. When you change how a projection or reducer builds a read model, you do not migrate the read model: you replay the observer and it rebuilds from history. The CLI does this with the `observers replay` command, and `replay-partition` does it for a single event source. The [replay a projection](/cli/scenarios/replay-a-projection/) scenario walks through it. Replay is an operation, not a check. The event log is untouched, but the observer discards its accumulated state and rebuilds from sequence zero, so the read model is unavailable or incomplete while it catches up. Run it deliberately, after the changed projection is deployed, and prefer `replay-partition` when only one event source is affected. Specifications are the pre-deployment check: they run in process against seeded events and touch no store. Reactors need extra care, because Chronicle’s .NET client replays them unless the class is marked `[OnceOnly]`; read the [reactor replay rules](/chronicle/clients/where-the-clients-differ/#reactor-replay-and-delivery-identity) before replaying one. ### Review stays human Nothing here removes the reviewer. It changes what the reviewer reads: an event model and a set of specifications that state intent, plus a diff that has already passed the build and the specs. The record that remains after a change is the log itself. Each event carries correlation, causation and identity, so a later audit can follow a state back to the command that caused it. ## Brownfield: start from signals with Prologue Most teams are not starting from nothing. The expensive step in moving an existing system to this way of working is the event model: someone has to read years of accreted code and write down what the system does. [Prologue](/prologue/) adds observed evidence to that step. It stands beside the running system, without application code changes, and watches selected signals: state-changing HTTP requests, which tables and columns changed after each one, and the telemetry the system already emits. It correlates these into captures, and the Interpreter proposes modules, features and slices with candidate commands, events, read models and projections. It produces a structured result and a draft [Screenplay](/screenplay/) file. Treat the output as another input to human modeling, not a recovered specification. Prologue records no database row values and no HTTP request or response bodies, but endpoint paths, changed column names and telemetry attributes can still be sensitive, so minimize access and protect the capture files. It cannot see a rule the system enforces without leaving a trace. It is the wrong tool for a greenfield system, for a system with no HTTP or database write path to observe, and for data migration. See [why Prologue](/prologue/why-prologue/) for the full list. Prologue is experimental. ## What is still experimental Maturity labels below follow the [capability map](/cratis-stack/). A label says how far the product has come, not how well it works for you. | Part | Role in this practice | Maturity | | --- | --- | --- | | Chronicle, Arc, Specifications | Event store, application framework and spec library | Released | | Chronicle MCP server | Agent access to a running store, read-only by default | Released | | CLI | Inspection, diagnosis and `cratis ai install` | Released | | Cratis AI | Rules, skills, prompts and agents installed into a repository | Preview | | Studio | Collaborative design of Screenplay event models | Preview, paid product | | Screenplay | The `.play` modeling language | Experimental | | Stage | Compiles Screenplay, verifies modeled specifications, renders an application | Experimental | | Prologue | Proposes an event model from an existing system | Experimental | Three limits are worth stating plainly: * **Stage is partial.** Its renderer is the priority path. Its runtime performs only the subset of modeled backend behavior it understands, and its specification verification is useful but does not prove every Screenplay construct has executable semantics. * **Cratis AI is unpinned.** `cratis ai install` reads the current `main` of the Cratis AI repository. To fix a revision, point `--source` at a checkout of a known commit. * **Agents still make mistakes.** The mechanisms on this page make mistakes visible and cheap to catch. They do not make an agent correct. Keep the review step, and keep the specs honest. The [roadmap](/roadmap/) and [what’s new](/whats-new/) track movement on these. ## Frequently asked questions ### What does “agentic development” mean here? It means an AI agent does a meaningful share of the work: reading the codebase, writing changes, running the checks and diagnosing a running system, with a person setting direction and reviewing. On this page it is paired with event sourcing because the log, the commands and the specifications give the agent explicit things to read and verify. ### Why does event sourcing help AI coding agents? It makes state, history and behavior explicit. The log records every change as a named fact, so the agent can inspect why state is what it is. Commands name every way the system can change. Specifications give an executable definition of correct behavior. An agent working against explicit contracts has less to guess. ### Do I have to use Cratis to do this? No. The practice does not depend on one product: a typed command model, an append-only log and executable specifications work with other tools. Cratis supplies one integrated set, from the event store and framework to the rules, specifications and MCP server. For a comparison of event stores, see [event sourcing in .NET](/compare-event-sourcing-dotnet/) and [on the JVM](/compare-event-sourcing-jvm/). ### Which coding agents does Cratis AI support? The managed install targets Claude Code, Codex, GitHub Copilot, Cursor, OpenCode and Pi. Marketplace plugins cover Claude Code, Codex, GitHub Copilot and Cursor with skills only. The [harness guide](/ai/harnesses/) has the command for each. ### Can an agent change my production data through the MCP server? Not by default. The server starts read-only and exposes no tool that changes state. The `Mutation` profile adds job control (stop, resume, delete) behind an explicit, time-limited policy. Application state changes only through commands and events, never through the MCP server. ### Does the MCP server work with my Chronicle client language? Yes. It connects to the Chronicle server over gRPC, so it works with the .NET, TypeScript, Kotlin/Java and Elixir clients alike. A running Chronicle server is required. ### Where do I start? Install the rules and skills with `cratis ai install`, connect the MCP server or run `cratis init`, and build one slice by following [build a full-stack feature](/build-a-full-app/). Then read the [Decisions that hold](/decisions-that-hold-walkthrough/) walkthrough to see a rule enforced from model to specification to frontend. [Start using Cratis AI](/ai/getting-started/) covers the first step in detail. ### How do I stop an agent from writing specs that pass but assert the wrong thing? Treat the specification diff as the main review artifact, and derive specs from the event model. A spec should read as a sentence a domain expert accepts. If the agent changed both a rule and the spec that checked it in one commit, that is the change to read first. ### Is this ready for production? Chronicle, Arc, Specifications, the CLI and the MCP server are released. See [production readiness](/production-readiness/) for what to check before you depend on them. The model-first layer (Screenplay, Stage, Prologue) is experimental and is best used to explore and draft, with review, rather than as an unattended pipeline. ## Where to go next * [AI-native development](/ai/): the full tour of Cratis AI, the CLI and the MCP server. * [Chronicle MCP server](/chronicle-mcp/): tools, configuration and deployment profiles. * [Testing with Cratis](/testing-with-cratis/) and [Specifications](/specifications/): the specification model in detail. * [Event modeling](/event-modeling/): the model the agent and the team share. * [The Cratis stack](/cratis-stack/): the products and a capability map with maturity labels. # AI-native development > Give your coding agent Cratis conventions through Cratis AI rules and skills, and let it inspect a running Chronicle store through the CLI and the Chronicle MCP server. The Cratis Stack ## Build and operate with AI agents Give your coding agent the same conventions, framework boundaries, and checks your team works to. Install the Cratis AI rules and skills into a repository with the Cratis CLI, or add the skills to one tool through its plugin. Then connect the agent to a running Chronicle store through the CLI and the Chronicle MCP server. For the case behind this approach — why explicit events, commands and specifications make agent-written changes verifiable — read [Agentic development with event sourcing](/agentic-development/). ## Why “AI-native” An assistant is only as good as what it knows about your framework and your system. Drop a general-purpose agent into a Cratis codebase and it guesses: it invents handler classes, misses the vertical-slice conventions, and hand-writes the API client that proxy generation already produces. Point it at a running event store and it has no idea how to read the log or find a failed partition. Cratis addresses both halves. On the build side, Cratis AI gives the agent the conventions. On the operate side, the CLI and the Chronicle MCP server give it a documented way into the store. ## Build with AI: skills that know the Cratis way **Cratis AI** is a shared corpus of rules, skills, prompts, and subagents that teaches an assistant the conventions: vertical slices, `[Command]` with `Handle()` on the record, model-bound projections, and `ConceptAs` instead of raw primitives. It is authored in [Cratis/AI](https://github.com/Cratis/AI) and reaches you through one of three channels: | Channel | What you get | Versioning | | --- | --- | --- | | `cratis ai install` | Rules, skills, prompts, agents, and hooks for every selected coding tool, filtered by profile and committed to your repository | Takes the current `main`; `--source` reads a checkout of a known commit | | `@cratis/pi` on npm | Skills, rules, prompts, agents, and hooks inside Pi | Versioned per release | | `cratis` marketplace plugin for Claude Code, Codex, GitHub Copilot, or Cursor | Skills only | Follows the default branch | A few of what you get, by name: * **`new-vertical-slice`** (prompt) scaffolds a whole feature end to end: command, events, projection, query, React, and specs. * **`cratis-arc-command`**, **`cratis-chronicle-read-model`**, **`cratis-chronicle-projection`**, **`cratis-chronicle-reactor`**, and **`cratis-fundamentals-concept`** (skills) build one artifact by convention. The `add-projection`, `add-reactor`, and `add-concept` prompts start the same work as a slash command. * **`scaffold-feature`** and **`write-specs`** (prompts) and **`cratis-code-review`** (skill) set up a feature folder, cover it with BDD specs, and review the result against the project’s standards. Prompts come with the managed install and `@cratis/pi`; the marketplace plugin has the skills only. Because the skills encode the conventions, an agent that uses them produces slices that look like the rest of your codebase instead of a layered approximation of it. Start with [Start using Cratis AI](/ai/getting-started/). The [agent harness guide](/ai/harnesses/) has the command for each coding tool, [profiles](/ai/profiles/) explains how to choose the guidance a repository needs, and the [scenarios](/ai/scenarios/solo-developer/) cover solo, team, and multi-tool setups. The [code-analysis rules](/code-analysis/) enforce the same framework contracts during the build, whether or not an agent wrote the code. Model first, then build You can pair this with [Studio](/studio/) or [Screenplay](/screenplay/): model the feature, then let an agent build the slices around it. Studio is in Preview; review generated output from Stage, which remains experimental. ## Operate with AI: teach your assistant your store Building is half the loop. The other half is *operating* what you built. Cratis gives an assistant two ways into a running store. ### `cratis init` — the CLI, made AI-aware Run it once inside your project: ```bash cratis init ``` It writes a **`CHRONICLE.md`** describing every command the CLI can run, adds instruction files for the AI tools it detects (Claude Code, GitHub Copilot, Cursor, Windsurf, and Pi), and adds a **`chronicle-diagnose`** slash command. Detection looks at the project’s files and at environment variables each tool sets. If it detects none, it configures none; pass `--tool ` to target one. From then on your assistant can browse events, watch observers, and diagnose a failed partition through the [CLI](/cli/), because the command catalog is in its context. After a CLI upgrade, refresh the catalog with `cratis init --refresh`. The [CLI getting started](/cli/getting-started/) guide covers it in full. ### The Chronicle MCP server — an agent, connected to the store For tools that speak the **Model Context Protocol**, Cratis publishes the [Chronicle MCP server](/chronicle-mcp/) as the `cratis/chronicle-mcp` container image. It connects straight to a running Chronicle server. The setup depends on your MCP host; [Chronicle MCP getting started](/chronicle-mcp/getting-started/) has a working configuration and [configuration](/chronicle-mcp/configuration/) covers connection strings and credentials. Local defaults Without configuration, the server uses Chronicle’s development client credentials and `localhost:35000`. Those defaults are for a local development store only. Give it real credentials, or an API key, before you point it at anything shared. Once connected, your assistant can: * **Explore**: list event stores, namespaces, event types, projections, and read models; read events and the tail sequence number; look at read model instances. * **Check observer health**: list observers and failed partitions, get one observer’s state, and list the recommendations the store has raised. * **Manage jobs**: list jobs and their steps, and stop, resume, or delete a job. * **Design against real data**: describe the system and its event types, build an event catalog, find event types nothing consumes, trace why an event source ended up in its state, run an ad-hoc projection, suggest next event types, and scaffold a read model. To replay an observer or recover a failed partition, use the [CLI](/cli/chronicle/) or the Workbench after the agent has helped you diagnose the cause. Operate, not change history The MCP server and the CLI inspect and operate the store: they read the log and manage observers and jobs. To change application state you still go through commands and events, so history stays honest. ## The whole loop, AI-accelerated ``` Operate with AI Build with AI scaffold slices run it inspect, diagnose, replay browse, check health, manage jobs Cratis AI rules · skills · prompts cratis init CLI command catalog Chronicle MCP server connected to the store Your Cratis app Running Chronicle store ``` ## Where to go next [ Get started with AI](/ai/getting-started/) [Install Cratis AI into a repository and confirm your agent sees it.](/ai/getting-started/) [ Ecosystem support](/ai/ecosystems/) [Which coding tools get what, through the managed install and each plugin.](/ai/ecosystems/) [ Distribution](/ai/trust-and-distribution/) [The three channels, how each is versioned, and how your own guidance stays yours.](/ai/trust-and-distribution/) [ The Cratis Stack](/cratis-stack/) [How design, build, and operate fit together end to end.](/cratis-stack/) [ CLI getting started](/cli/getting-started/) [Install the CLI, connect to your store, and run `cratis init` to set up AI tooling.](/cli/getting-started/) [ Chronicle MCP](/chronicle-mcp/) [The operate-side and design-time tools an agent gets against a live store.](/chronicle-mcp/) [ Skills and plugins](/plugins/) [What the corpus contains, and which channel delivers rules, skills, prompts, and hooks.](/plugins/) [ Code analysis](/code-analysis/) [The Roslyn analyzers and ESLint rules that enforce the same conventions during the build.](/code-analysis/) [ Vertical slices](/arc/vertical-slices/) [The convention the build-side skills follow: everything for a feature in one folder.](/arc/vertical-slices/) # Cratis AI concepts > The terms used across the Cratis AI pages, including corpus, profile, rule, skill, prompt, managed manifest, harness adapter, and native plugin. The Cratis AI pages use a small set of terms. This page defines them in one place. | Concept | Meaning | | --- | --- | | **Corpus** | The shared rules, skills, prompts, agents, hooks, and harness assets. It is authored in [Cratis/AI](https://github.com/Cratis/AI) and installed into a repository under `.cratis/ai`. | | **Configuration** | `.cratis/ai.json`, the repository’s own record of which harnesses, profiles, and languages it selected. It has no version field. | | **Profile** | A named selection. `cratis/application/*` profiles are for building applications on Cratis, `cratis/engineering/*` for contributing to the framework, `cratis/documentation` for documentation. Product profiles such as `cratis/arc` select skills for one product. See [Choose Cratis AI profiles](/ai/profiles/). | | **Rule** | Standing guidance the harness loads without being asked. Some rules apply to every file; others are scoped to paths such as `**/*.cs`. | | **Skill** | A folder with a `SKILL.md` and optional references that the agent loads when a task matches. Cratis skill names start with `cratis-`, for example `cratis-arc-command`. | | **Prompt** | A slash command that starts a workflow, such as `new-vertical-slice` or `add-projection`. | | **Agent** | A subagent definition, such as a spec writer or a security reviewer, that a harness with subagent support can delegate to. | | **Managed manifest** | `.cratis/ai.manifest.json`, which records the corpus source revision and every Cratis-installed file and harness integration with its hash. | | **Harness** | The coding tool that runs the agent: Claude Code, Codex, GitHub Copilot, Cursor, OpenCode, or Pi. | | **Harness adapter** | A native path such as `.claude/skills`, `.cursor/rules`, `.pi/extensions`, or `AGENTS.md` that points into `.cratis/ai`, usually as a symbolic link. | | **Native plugin** | A single-harness install that bypasses the CLI: the `cratis` marketplace plugin (skills only) or the `@cratis/pi` npm package. | The repository owns its configuration and every file it adds. The installer owns only the paths listed in the managed manifest. A file it did not install is never claimed or overwritten, even with `--force`; if such a file is in the way, the run reports a conflict and changes nothing. Use [Start using Cratis AI](/ai/getting-started/) for the managed workflow and the [agent harness guide](/ai/harnesses/) to compare native plugins. # Use AI as a Cratis maintainer > Work on a Cratis framework or product repository with an AI agent, using the engineering profiles, the repository's own context, fresh verification, and reviewed pull requests. You maintain a Cratis repository (Arc, Chronicle, a client SDK, Components, the CLI, this documentation) and want an agent to do real work in it. The agent should not guess how the framework works; the repository already carries the instructions it needs. Your part is to start it in the right place, state the outcome, and insist on fresh verification before anything ships. ## Work from the repository root 1. **Start the assistant in the repository you intend to change** The root `AGENTS.md` (or `CLAUDE.md`) links to the repository’s own instructions and the installed Cratis rules. Profile selection matters: application vertical-slice rules do not apply to Chronicle, Arc, Components, or client-library source. A framework repository installs a `cratis/engineering/` profile, for example: ```bash cratis ai install \ --harnesses claude,codex,copilot,cursor,opencode,pi \ --profiles cratis/engineering/csharp,cratis/engineering/typescript,cratis/documentation \ --languages csharp,typescript ``` Add a product profile such as `cratis/arc` or `cratis/chronicle` when you want that product’s skills as well. [Choose Cratis AI profiles](/ai/profiles/) explains the split. 2. **Read project-owned context** Project-specific instructions live in `.cratis/ai/rules/project.md` and the concern files it lists. Some repositories still carry a legacy `.cratis/PROJECT.md`; the installer can split it into the new layout. Keep that context in the repository you are working in. Never replace, concatenate, or copy another repository’s managed AI corpus. 3. **Ask for the outcome, not an implementation pattern** Good requests name the behavior and the constraints: * “Model the registration flow before implementation.” * “Add a strongly typed invoice number.” * “Diagnose why this projection stopped updating.” * “Review this change for Cratis architecture and security.” A matching skill guides the workflow. If you know the skill or prompt, name it through your harness’s skill picker or slash command. 4. **Let the repository answer routine questions** The assistant should inspect local rules, source, generated patterns, and specs before interrupting you. It should stop for product choices, missing authority, credentials, risky production actions, or evidence that genuinely conflicts. 5. **Require fresh gates** Compilation is not evidence of behavior. Run the repository’s current build, specs, lint, generated-file, and documentation checks, and run the failing check again after a fix. 6. **Ship through a reviewed pull request** Use logical commits, green CI, and the repository’s merge policy. When the work creates a new repository in the Cratis organization, prepare a Strategy intake proposal with the facts Strategy needs (purpose, owner, lifecycle, dependencies, release and security expectations). The proposal is a draft for a maintainer to route; an agent does not open, comment on, or link `Cratis/Strategy` issues on its own. ## Common maintainer workflows Skills are installed when a selected profile includes them; the marketplace plugins carry all of them. Prompts are installed with every managed profile. | Intent | Skill or prompt | | --- | --- | | Design commands, events, streams, views, or reactions before building | `cratis-chronicle-event-modeling`, or `cratis-screenplay-event-modeling` for a `.play` model | | Build a complete application behavior | `new-vertical-slice` prompt | | Add a command, read model, projection, reactor, reducer, or concept | `cratis-arc-command`, `cratis-chronicle-read-model`, `cratis-chronicle-projection`, `cratis-chronicle-reactor`, `cratis-chronicle-reducer`, `cratis-fundamentals-concept`; or the `add-projection`, `add-reactor`, `add-reducer`, `add-concept` prompts | | Diagnose a stale read model or a stopped reactor | `cratis-chronicle-projection`, `cratis-chronicle-reactor` | | Inspect a running Chronicle store | `cratis-chronicle-cli-operations`, `cratis-chronicle-mcp-inspection`, `cratis-cli-terminal-workbench` | | Review correctness, architecture, security, or performance | `cratis-code-review`, `cratis-security-review`, `cratis-performance-review`; or the `code-review` and `review-pr` prompts | | Write product or site documentation | `cratis-documentation-writing`, `cratis-engineering-docs-authoring`, `cratis-technical-examples` | | Record an architectural decision | `cratis-engineering-decision-record` | | Commit, push, open, monitor, and merge a pull request | `ship-changes` prompt | Skills guide the work; analyzers still enforce contracts The AI workflow does not replace Arc or Chronicle analyzers, compiler errors, specifications, CI, or human review. It helps an agent reach the same gates a maintainer would use. ## Do not propagate the corpus Do **not** copy shared `.cratis/ai`, `.claude`, `.github`, `.agents`, or `.pi` trees from one repository to another. Copying created mixed versions, unclear ownership, and changes that fanned out by accident, so it is no longer done. Shared guidance reaches a repository through one of three channels instead: 1. `cratis ai install` and `cratis ai update`, which copy the current `main` of `Cratis/AI` (or a `--source` checkout) and record the source commit and file hashes; 2. the `@cratis/pi` npm package, versioned per release; or 3. the native marketplace plugins, which carry skills only. Change shared guidance in `Cratis/AI` through a reviewed pull request, then update consuming repositories through their channel. Roll back by reinstalling from a known-good commit with `--source`, or by pinning an earlier `@cratis/pi` version. [Cratis AI distribution](/ai/trust-and-distribution/) describes the channels in full. Some repositories still carry repository-local AI files from before the managed install. Leave them in place unless a reviewed change retires them, and do not restart copying between repositories. ## Keep shared and project knowledge separate Shared skills own Cratis conventions and reusable workflows. The consuming repository owns architecture decisions, commands, environments, how credentials are obtained, fixtures, and verification details. Keeping them apart lets the shared corpus update without overwriting the facts that make one project different from another. Remove private or project-specific facts before you propose a reusable improvement to `Cratis/AI`. Read [Cratis AI distribution](/ai/trust-and-distribution/) for the ownership rules and [Start using Cratis AI](/ai/getting-started/) for the setup steps. # AI ecosystem support > Which Cratis AI content reaches Claude Code, Codex, GitHub Copilot, Cursor, OpenCode, and Pi through the managed install and through each native plugin. Cratis authors one corpus and exposes it through each harness’s native folders. The adapter changes how a harness discovers the content, not what the rule or skill says. What a harness receives depends on the channel you install through. ## Support matrix | Harness | Managed install (`cratis ai install`) | Native channel | Native channel content | | --- | --- | --- | --- | | Claude Code | `CLAUDE.md`, `.claude` | `cratis` marketplace plugin | Skills | | OpenAI Codex | `AGENTS.md`, `.agents/skills` | `cratis` marketplace plugin | Skills | | GitHub Copilot | `.github` | `cratis` marketplace plugin | Skills | | Cursor | `.cursor` | `cratis` marketplace plugin | Skills | | OpenCode | `AGENTS.md`, `.opencode` | None | Use the managed install | | Pi | `AGENTS.md`, `.pi` | `@cratis/pi` on npm | Skills, rules, prompts, agents, subagent tool, quality hooks | The managed install gives each selected harness the integrations it supports (rules, skills, prompts, and agents; Codex gets only `AGENTS.md` and skills) from one committed copy, filtered by profile and language. It is the recommended path for a repository that more than one tool or person works in. The [agent harness guide](/ai/harnesses/) lists exactly what it creates for each harness. Harnesses not in this table are not supported by the CLI or the marketplace manifests. A tool that reads `AGENTS.md` may still pick up the root instructions, but Cratis does not test or document that. ## Versions | Channel | Where it reads from | Can you pin it? | | --- | --- | --- | | Managed install | Current `main` of `Cratis/AI`, or a local checkout with `--source` | Only by pointing `--source` at a checkout of a known commit | | Marketplace plugins | Current default branch of `Cratis/AI` | No | | `@cratis/pi` | Published npm releases | Yes, by package version | [Cratis AI distribution](/ai/trust-and-distribution/) explains the ownership and update rules behind these channels. ## Quality The `Cratis/AI` repository checks corpus structure, profile composition, the harness adapters, and the Pi package in CI, and loads the corpus through Pi’s resource loader to prove the skills, prompts, and extensions are discovered. Per-skill checks are static: they confirm that a skill contains required guidance. They do not run a model against the skill, compile its examples, or measure how well an agent performs with it. # Start using Cratis AI > Install Cratis rules, skills, agents, and prompts into a repository with the Cratis CLI, or add the skills to a single coding tool through its native plugin. You want your coding agent to write Cratis code the way the framework expects: commands with `Handle()` on the record, model-bound projections, `ConceptAs` instead of raw primitives. Cratis AI is the shared guidance that teaches it that. This page gets it into a repository and shows how to tell it worked. The guidance comes in four kinds. **Rules** are standing instructions; some load for every file, others only for matching paths such as `**/*.cs`. **Skills** are focused task workflows the agent loads when a task matches. **Prompts** are slash commands such as `new-vertical-slice`, and **agents** are subagent definitions. ## Pick a channel | You want | Use | What you get | | --- | --- | --- | | One consistent setup for a repository, its team, and several coding tools | `cratis ai install` (this page) | Rules, skills, prompts, agents, and hooks, filtered by profile and committed to the repository | | Cratis skills in one tool on your own machine | The tool’s [native plugin](/ai/harnesses/#claude-code-plugin) | Skills only | | Cratis guidance in Pi, pinned to a package version | [`@cratis/pi`](/ai/harnesses/#pi-package) | Skills, rules, prompts, agents, and quality hooks | [Cratis AI distribution](/ai/trust-and-distribution/) explains how the three channels are versioned and why the CLI path is the only one that manages a shared repository copy. ## Complete repository setup **Prerequisites:** the [Cratis CLI](/cli/getting-started/), and `git` with network access to GitHub. The CLI clones the current `main` branch of [Cratis/AI](https://github.com/Cratis/AI) on each install and update, unless you point it at a local checkout with `--source`. 1. From the repository root, preview what would be written: ```bash cratis ai install --dry-run ``` In a terminal, the CLI asks you to pick harnesses (your coding tools), profiles, and languages. The choices come from the corpus’s own `.cratis/ai/manifest.json`, so they always match what is published. 2. Run the same command without `--dry-run`. In a script, CI, or an agent’s terminal there is no prompt, so pass the selection explicitly: ```bash cratis ai install \ --harnesses claude,codex,copilot,cursor,opencode,pi \ --profiles cratis/application/csharp,cratis/documentation \ --languages csharp,typescript ``` [Choose Cratis AI profiles](/ai/profiles/) explains which profile fits your repository. Name your languages: rules scoped to C# or TypeScript files are installed only for the languages you list. 3. Check the result: ```bash cratis ai status ``` It prints the selected profiles, harnesses, and languages, the corpus revision installed, and whether any managed file was changed locally. 4. Commit `.cratis/ai.json`, `.cratis/ai.manifest.json`, the `.cratis/ai/` tree, and the harness adapters (`AGENTS.md`, `CLAUDE.md`, `.claude/`, `.pi/` and so on). Everyone on the team and every CI run then reads the same guidance. To confirm the agent actually sees it, start your coding tool from the repository root and ask it to list the Cratis skills or prompts it can use. The names start with `cratis-`, for example `cratis-arc-command` and `cratis-chronicle-projection`. Scaffolded from a template? The Cratis `dotnet new` templates already contain a `.cratis/ai.json`. Skip `install` and run `cratis ai update` once to download the guidance it selects. `cratis new` runs that step for you. ### What the installer writes The CLI copies the selected corpus into `.cratis/ai/`, records each installed file and its hash in `.cratis/ai.manifest.json`, and creates native adapters for each selected harness. Most adapters are symbolic links into `.cratis/ai/`, so every tool reads the same copy. The [agent harness guide](/ai/harnesses/) lists what each harness receives. Pi is configured directly from the managed copy. The managed `cratis-rules` extension puts the universal rules from `.cratis/ai/rules` into the system prompt, and `cratis-path-guidance` attaches path-scoped rules and skill hints the first time a matching file is touched. You do not need the `@cratis/pi` package as well. Where your own files can go The installer never overwrites a file it did not install. If one of your files sits where a Cratis file would go, the run reports a conflict and changes nothing, even with `--force`. Rename your file or move it. Adapter folders such as `.claude/skills` are links into `.cratis/ai/`. A file you create inside a linked folder lands in the shared managed tree and is seen by every harness. Keep tool-private files outside the linked folders. Selecting a Screenplay profile also writes a managed `screenplay` MCP entry into each harness’s MCP configuration, including files you already own, alongside your own entries. Set `mcpServers.screenplay.enabled` to `false` in `.cratis/ai.json` to disable it. The [agent harness guide](/ai/harnesses/) lists the files. ## Share project-specific guidance Put guidance that belongs to this repository alone in `.cratis/ai/rules/project.md`. When it covers several concerns, keep that file a short index and give each concern its own file: ```text .cratis/ai/rules/ ├── project.md └── project/ ├── build-and-test.md ├── architecture.md ├── security.md └── deployment.md ``` `project.md` tells harnesses that read one context file to read every concern. Native rule adapters and Pi’s rules extension discover the concern files directly. The CLI creates `AGENTS.md` and `CLAUDE.md` as links to `project.md` when those paths are free. If `AGENTS.md` or `CLAUDE.md` already exists as your own file, the CLI leaves it alone. Your existing file then does not mention the managed rules; add a line that tells the agent to read `.cratis/ai/rules/project.md`. ### Moving from a legacy project file The installer detects `.cratis/PROJECT.md`, `.ai/PROJECT.md`, and `.agents/PROJECT.md`. It copies their headings into `project.md` and one concern file per heading, without deleting the original. Review the split, remove the legacy file and anything that points to it, and run `cratis ai update` again so the shared adapters are created. ## Maintain the installation ```bash cratis ai status # read-only; exits non-zero if a managed file was edited cratis ai update --dry-run # what an update would change cratis ai update # take the current corpus for the recorded selection cratis ai uninstall # remove unchanged Cratis-managed content and adapters ``` `update` re-reads `.cratis/ai.json` and takes whatever the corpus `main` branch holds now. There is no version pin on this channel. To change the selection, run `install` again with new options. `update` and `uninstall` act only on files the manifest records as Cratis-managed. When one of those files was edited locally, both stop before changing anything and list the paths. Restore the file, or pass `--force` once you have decided the local edit can be replaced or removed. Because `status` exits non-zero on local edits, you can run it in CI to catch drift in managed guidance. [Updates and rollback](/ai/scenarios/updates-and-rollback/) covers previewing, pinning with `--source`, and going back. ## Choose the integration that fits your repository ### Managed by Cratis Use `cratis ai install` when several people or several coding tools work in the repository, or when you want the guidance reviewed and committed like code. It is the only channel that installs rules for every harness, filters by profile, and protects local edits on update and uninstall. ### Native plugin or marketplace Use a native plugin when one developer wants Cratis skills in one tool without touching the repository. The Claude Code, Codex, GitHub Copilot, and Cursor marketplace plugins deliver the skills only, from the current `Cratis/AI` default branch. `@cratis/pi` also contributes rules, prompts, agents, a subagent tool, and quality hooks, and it is versioned on npm. Neither creates `.cratis/ai`, configures other harnesses, or protects local edits. The [agent harness guide](/ai/harnesses/) has the commands. ## Contribute and verify skills Skills are authored and reviewed in [Cratis/AI](https://github.com/Cratis/AI). A skill can carry a `verification.json` with an example input and assertions that the skill contains the guidance that input needs. These checks are static: they do not run the input through a model, compile the skill’s examples, or judge its writing. Judging how an agent behaves with a skill still takes a real task and a review of the output. Questions? Ask on Discord Ask questions and get help from the Cratis team and other developers on the [Cratis Discord](https://discord.gg/kt4AMpV8WV). See [Community and help](/community/) for where else to go. # Agent harness guide > What the managed Cratis AI install creates for Claude Code, Codex, GitHub Copilot, Cursor, OpenCode, and Pi, and the commands for each native plugin. A *harness* is the coding tool that runs the agent: Claude Code, Codex, GitHub Copilot, Cursor, OpenCode, or Pi. Each one looks for instructions and skills in its own folders. This page lists what Cratis puts in those folders, and how to install the native plugin when you only need one tool. Cratis AI reaches a harness in one of two ways: * **Managed repository setup.** `cratis ai install` resolves the repository’s profiles and languages, installs the shared corpus in `.cratis/ai`, and wires every selected harness to it. See [Start using Cratis AI](/ai/getting-started/). * **Native plugin setup.** You install one harness’s plugin or package without the Cratis CLI. You get no shared repository copy, no multi-harness wiring, and no protection for local edits on update. Use the managed path when a team wants the same guidance across tools. Use a native plugin when one developer wants Cratis skills in one tool. ## Managed harness support ```bash cratis ai install \ --harnesses claude,codex,copilot,cursor,opencode,pi \ --profiles cratis/application/csharp \ --languages csharp,typescript ``` The accepted harness values come from the corpus’s `.cratis/ai/manifest.json`. The CLI creates adapters only for the harnesses you select: | Harness | Managed project integration | | --- | --- | | Claude Code | `CLAUDE.md`, plus `.claude` rules, agents, commands, prompts, skills, and hook settings | | OpenAI Codex | `AGENTS.md` and `.agents/skills` | | GitHub Copilot | `.github/copilot-instructions.md`, plus `.github` instructions, agents, prompts, and skills | | Cursor | `.cursor` rules, agents, commands, and skills | | OpenCode | `AGENTS.md`, plus `.opencode` agents, commands, and skills | | Pi | `AGENTS.md`, plus `.pi` extensions (rules loading, path guidance, Screenplay MCP, subagents, quality hooks), agents, prompts, and skills | Most adapters are symbolic links into `.cratis/ai`, so no harness gets its own diverging copy. `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` are created only when you do not already have your own file at that path. Run `cratis ai status` to see local edits to managed files, and `cratis ai uninstall` to remove the integrations the CLI created. The Screenplay profile also registers an MCP server When a selected profile resolves to `cratis/screenplay`, the CLI also adds a managed `screenplay` MCP server entry (`cratis screenplay mcp`) to each harness’s MCP configuration, next to your own entries: `.mcp.json` (Claude Code), `.vscode/mcp.json` (Copilot), `.cursor/mcp.json`, `.codex/config.toml` and `opencode.json`. Pi reaches the same server through its managed `cratis-mcp` extension. To turn it off, set `mcpServers.screenplay.enabled` to `false` in `.cratis/ai.json`. Your harness folders remain yours The installer refuses to overwrite a file it did not install. You can add your own files next to the Cratis links in any harness folder. A folder that is itself a link, such as `.claude/skills`, points into `.cratis/ai`: anything you put there becomes part of the shared tree that every harness reads. `--force` only replaces or removes files already recorded as Cratis-managed. ## Native plugins The Claude Code, Codex, GitHub Copilot, and Cursor plugins come from the marketplace manifests in [Cratis/AI](https://github.com/Cratis/AI). They carry the **skills only**, read from the repository’s current default branch, so there is no version to pin. Rules, prompts, agents, and hooks are not included; use the managed install for those. ## Claude Code plugin ```text /plugin marketplace add Cratis/AI /plugin install cratis@cratis ``` Remove it with `/plugin uninstall cratis@cratis`. ## OpenAI Codex plugin ```bash codex plugin marketplace add Cratis/AI codex plugin add cratis@cratis ``` Remove it with `codex plugin remove cratis@cratis`. ## GitHub Copilot plugin These commands are for the GitHub Copilot CLI: ```bash copilot plugin marketplace add Cratis/AI copilot plugin install cratis@cratis ``` ## Cursor plugin Add `Cratis/AI` in Cursor’s plugin settings. Cursor reads the committed `.cursor-plugin/marketplace.json` and installs the Cratis skills. ## Pi package `@cratis/pi` is published to npm and versioned per release, which makes it the one native channel you can pin. ```bash # Project-scoped (written to .pi/settings.json) pi install -l npm:@cratis/pi # User-wide pi install npm:@cratis/pi # Pinned to an exact version pi install -l npm:@cratis/pi@ ``` Pi treats a versioned npm source as pinned: `pi update` does not move it. Find the published versions with `npm view @cratis/pi versions`. The package reads `.cratis/ai.json` when present and exposes the skills for the selected profiles and languages. Without that file, it exposes every packaged skill. Unlike the marketplace plugins, it also contributes rules, prompts, agents, a subagent tool, and Cratis quality hooks. It does not create `.cratis/ai`, configure other harnesses, or protect local edits. When the repository already has a managed install (`.cratis/ai.manifest.json` exists), the package steps aside so the same rules and hooks are not registered twice. ## OpenCode without the CLI There is no Cratis OpenCode plugin or marketplace entry. OpenCode reads `AGENTS.md` and `.opencode/agents`, `.opencode/commands`, and `.opencode/skills` from the project, so to get Cratis content into OpenCode, use the managed install with `--harnesses opencode`. You can add your own content to those folders either way. # Choose Cratis AI profiles > Pick the Cratis AI profiles and languages that match what your repository builds, so the agent gets application, framework, or documentation guidance and not the wrong set. A profile decides which Cratis guidance a repository receives. The choice that matters is *what the repository builds*: an application on Cratis, part of the Cratis framework itself, or documentation. Get that wrong and the agent follows rules written for a different kind of project. ## Choose the profile | Repository | Profile | | --- | --- | | .NET application on Arc and Chronicle, with a React frontend | `cratis/application/csharp` | | .NET application on Arc without Chronicle | `cratis/application/arc-only` | | Application on the Kotlin clients | `cratis/application/kotlin` | | Application on the TypeScript Chronicle client | `cratis/application/typescript` | | A Cratis framework repository (library, kernel, client SDK) | `cratis/engineering/`, for example `cratis/engineering/csharp` | | Any repository with documentation | add `cratis/documentation` | `cratis/application` composes every application language profile. Prefer the language-specific one unless the repository really spans them. Only a `cratis/application*` profile brings in the application rules: vertical slices, model-bound commands and read models, the MVVM frontend. Only a `cratis/engineering*` profile brings in the framework-contributor rules. The product profiles, such as `cratis/arc` or `cratis/chronicle`, select skills for that product but deliver neither rule set on their own. The corpus’s `.cratis/ai/manifest.json` is the authoritative list of the profiles, languages, and harnesses `cratis ai install` accepts. An unknown value fails validation with the rejected value. Use the profile table above and the [CLI reference](/cli/ai/) to choose a supported selection. ## Configure the repository 1. Run the installer. In a terminal it prompts for each choice: ```bash cratis ai install ``` 2. Or pass every selection, which is required in scripts, CI, and agent terminals: ```bash cratis ai install \ --harnesses claude,cursor,opencode,pi \ --profiles cratis/application/typescript \ --languages typescript ``` 3. Review and commit `.cratis/ai.json`: ```json { "schemaVersion": "1.0", "harnesses": ["claude", "cursor", "opencode", "pi"], "profiles": ["cratis/application/typescript"], "languages": ["typescript"] } ``` The CLI expands composed profiles, keeps the language-specific parts that match your languages, and installs the matching skills. Agents, prompts, and hooks are always installed. Rules are filtered by profile and by language. Always list your languages `--languages` narrows the skills a profile composes, and omitting it leaves the skills unconstrained. Rules behave differently: a rule scoped to C# or TypeScript files is installed only when that language is listed. Name every language the repository uses. Configuration is not ownership `.cratis/ai.json` records what your repository asked for. There is no version field: the corpus version is whatever the CLI read at install or update time. `.cratis/ai.manifest.json` records which installed files and integrations Cratis owns, with their hashes. A file you create is never claimed because it lives in a managed or harness folder. ## Update or change a selection To change products, languages, or harnesses, run `install` again with the new options. To pick up a newer corpus for the selection you already have, run: ```bash cratis ai update ``` The update adds newly selected content, removes Cratis-owned content that is no longer selected, and reconnects the harness links. It stops before changing anything if a managed file was edited locally. Review that edit before you use `cratis ai update --force`. ## Use a native plugin instead A native plugin is independent of the managed path. It suits one person using one harness. The Claude Code, Codex, GitHub Copilot, and Cursor plugins deliver every Cratis skill and do not read profiles. The Pi package does: ```bash pi install -l npm:@cratis/pi ``` `@cratis/pi` reads `.cratis/ai.json` when it exists and exposes the skills for those profiles and languages; without it, the package exposes all skills. Neither kind of plugin creates the shared `.cratis/ai` corpus, configures other harnesses, or protects local edits. # Scenario: Cratis maintainer > Configure a Cratis framework or product repository with the engineering profiles and its native agent harness folders. You contribute to a Cratis framework or product repository instead of building an application on it. The framework-contributor rules apply; the application vertical-slice rules do not. ## Configure the repository Use the managed installer with a `cratis/engineering/` profile for each language the repository contains. Add `cratis/documentation` when it has documentation, and a product profile when you also want that product’s skills: ```bash cratis ai install \ --harnesses claude,codex,copilot,cursor,opencode,pi \ --profiles cratis/engineering/csharp,cratis/engineering/typescript,cratis/documentation,cratis/arc \ --languages csharp,typescript ``` A product profile such as `cratis/arc` on its own brings skills but neither the engineering nor the application rules. See [Choose Cratis AI profiles](/ai/profiles/). Start the agent from the repository root so it reads `AGENTS.md`, the installed Cratis rules, and the repository’s own guidance. ## Keep repository guidance local Put product-specific or confidential guidance in the owning repository, in `.cratis/ai/rules/project.md` and its concern files. Add your own skills under `.cratis/ai/skills/` with names that do not start with `cratis-`. The managed manifest records only the files Cratis installed, so your additions survive update and uninstall. Generalize reusable guidance before proposing it to `Cratis/AI`, and leave private product facts out of the shared corpus. ## Use a native plugin for one harness A maintainer who works in one harness on their own machine can install the `cratis` marketplace plugin (skills only) or `@cratis/pi` instead. Neither installs the engineering rules into the repository for the rest of the team. See [Use AI as a Cratis maintainer](/ai/cratis-maintainers/) for the complete workflow. # Scenario: multiple harnesses > Configure several AI coding tools from one managed Cratis AI corpus and one repository-owned selection. Your team uses more than one coding tool in the same repository: some people in Claude Code, some in Cursor, CI in Codex. You want all of them to follow the same Cratis guidance. Use the managed install. ## Configure the repository ```bash cratis ai install \ --harnesses claude,codex,copilot,cursor,opencode,pi \ --profiles cratis/application/csharp \ --languages csharp,typescript ``` Selecting a harness nobody uses only adds an unused adapter, so most repositories select all of them. Commit `.cratis/ai.json`, `.cratis/ai.manifest.json`, the managed `.cratis/ai` content, and the harness adapters. ## What you get * **One corpus.** Every adapter points into `.cratis/ai`; no harness gets a separately maintained copy. * **One selection.** Profiles and languages are resolved once for all selected harnesses. * **Native folders.** Each tool still reads `.claude`, `.cursor`, `.opencode`, `.pi`, or whichever folders it expects. * **Protected edits.** `cratis ai update` stops before overwriting a locally edited managed file, and never touches files Cratis did not install. Run `cratis ai status` before an update and `cratis ai uninstall` when you remove the managed integration. ## Native plugins remain optional A developer can still install a marketplace plugin or `@cratis/pi` on their own machine without the CLI. That path covers one harness, and the marketplace plugins carry skills only. It does not configure the other harnesses or protect local edits. # Scenario: solo developer > One person, one coding tool. Install the Cratis AI plugin for your harness and start; nothing to add to your repository. You work alone, in one coding tool, building on Cratis. ## What you do Install the plugin for your harness. The [agent harness guide](/ai/harnesses/) has the exact command for each one. For Claude Code: ```text /plugin marketplace add Cratis/AI /plugin install cratis@cratis ``` That is the whole setup. There is no `.cratis/` directory to create and no configuration file to commit. ## What you get Your assistant loads Cratis skills when a task matches one: writing a command, modeling an event flow, adding a projection, debugging a stale read model. The skills are Markdown instructions and references. The marketplace plugin adds no hooks, scripts, MCP server, or files in your project. The plugin carries skills only. It does not install the always-on Cratis rules, the slash-command prompts such as `new-vertical-slice`, or the subagents. If you want those, run `cratis ai install` in the repository (see [Start using Cratis AI](/ai/getting-started/)); in Pi, `@cratis/pi` includes rules and prompts. Your own configuration stays yours. Keep whatever you already have in `.claude/` (or your harness’s equivalent). The plugin does not touch it. ## Keeping current The marketplace plugin reads the current default branch of `Cratis/AI`. There is no version to pin; updating means updating the plugin in your harness, and going back means uninstalling it. `@cratis/pi` is the exception: it is published to npm with version numbers. See [updates and rollback](/ai/scenarios/updates-and-rollback/). ## Things to avoid Do not copy the `Cratis/AI` repository’s `.cratis/ai`, `.claude`, `.github`, or skill folders into your own repository by hand. The copy goes stale without warning. Use a plugin, or the managed install, which records what it installed and can update it. # Scenario: team repository > Commit one managed Cratis AI corpus and selection so everyone on the team, in every supported harness, sees the same guidance. Several people work in one repository, and you want every one of them, and CI, to get the same Cratis guidance. Commit it with the code. ## What you do Run `cratis ai install` in the repository root, select the harnesses, profiles, and languages the repository needs, then review and commit the result. | Path | What it carries | | --- | --- | | `.cratis/ai.json` | The selected harnesses, profiles, and languages. | | `.cratis/ai/` | Cratis-managed rules, skills, prompts, agents, and hooks, plus anything you add. | | `.cratis/ai.manifest.json` | The source commit, and each managed file’s source identity and installed hash. | | Harness adapters | Symbolic links or configuration entries pointing to `.cratis/ai`. | A configuration can select more than one harness and profile: ```json { "schemaVersion": "1.0", "harnesses": ["claude", "codex", "pi"], "profiles": ["cratis/application/csharp", "cratis/documentation"], "languages": ["csharp", "typescript"] } ``` ## What you get * **One source.** Harness integrations point to `.cratis/ai`; they do not carry diverging skill folders. * **Reviewable updates.** An update is a diff in a pull request. Run `cratis ai update --dry-run` first to see what will change. * **Protected edits.** The manifest hashes every managed file. Update and uninstall stop before changing anything if one was edited locally. * **Your own extensions.** Local rules and skills under `.cratis/ai` are never claimed, deleted, or overwritten by Cratis. To catch hand edits to managed guidance in CI, run `cratis ai status`; it exits non-zero when a managed file was modified. Resolving a modified managed file Run `cratis ai status` to see which paths changed. Restore the file, or use `cratis ai update --force` or `cratis ai uninstall --force` only when you deliberately want to replace or remove that edited managed content. If the edit is an improvement others would want, propose it to `Cratis/AI`. # Scenario: updates and rollback > How Cratis AI guidance changes for each channel, how to preview an update, and how to get back to a known-good state. An update to Cratis AI changes what your agent is told to do, so you want to see it coming and be able to undo it. How you do that depends on the channel you installed through. | Channel | What an update takes | How to go back | | --- | --- | --- | | Managed install | The current `main` of `Cratis/AI` | Reinstall from a checkout of a known-good commit with `--source`, or revert the commit that updated it | | `@cratis/pi` | A published npm version | Install the earlier version | | Marketplace plugins | The current default branch of `Cratis/AI` | Uninstall the plugin | ## Managed install `cratis ai update` is not pinned. It takes whatever the source holds when it runs. Preview first: ```bash cratis ai status # installed revision, available revision, local edits cratis ai update --dry-run # the actions an update would take, without writing cratis ai update ``` Because the managed content is committed, an update arrives as a diff you can review in a pull request, run your gates against, and merge like any other change. To stay on a specific revision, or to roll back after an update you do not want, point the CLI at a local checkout of that commit: ```bash git clone https://github.com/Cratis/AI ../cratis-ai git -C ../cratis-ai checkout cratis ai update --source ../cratis-ai --dry-run cratis ai update --source ../cratis-ai ``` The source commit of the current install is recorded in `.cratis/ai.manifest.json`. If the update is still an unmerged branch, discard it; if it was merged, reverting that commit restores the previous files as well. Edited managed files block updates If someone edited a managed file, `update` stops before changing anything. Decide whether the edit should be kept (and proposed to `Cratis/AI`) or replaced, then restore it or use `--force`. ## `@cratis/pi` The Pi package is the one channel with real version numbers: ```bash pi install -l npm:@cratis/pi # latest release pi install -l npm:@cratis/pi@ # a specific release, pinned npm view @cratis/pi versions # what has been published ``` To roll back, install the version you last trusted. A versioned source stays at that version until you change it. ## Marketplace plugins The Claude Code, Codex, GitHub Copilot, and Cursor plugins read the current default branch of `Cratis/AI`. There is no version to pin. Updating means updating the plugin in your harness; the only way back is to uninstall it. If you need a fixed version, use the managed install with `--source` instead. ## Uninstalling `cratis ai uninstall` removes unchanged Cratis-managed files and the adapters the CLI created. It never removes files you created yourself, such as your own `.cratis/ai` rules and skills or an `AGENTS.md` you wrote. Run it with `--dry-run` first to see the list. See [Cratis AI distribution](/ai/trust-and-distribution/) for the channels and ownership rules. # Cratis AI distribution > How Cratis AI guidance reaches a repository through three channels, how each is versioned, and how a managed install keeps repository-owned guidance intact. Cratis AI is authored in one place, the [Cratis/AI](https://github.com/Cratis/AI) repository, and reaches your repository or your coding tool through one of three channels. They differ in what they deliver, how you pin them, and who owns the files afterwards. This page explains those differences so you can pick a channel and know what an update will do. ## Three channels | Channel | Delivers | Version semantics | | --- | --- | --- | | `cratis ai install` / `cratis ai update` | Rules (filtered by profile and language), skills (filtered by profile), prompts, agents, hooks, and harness adapters, copied into the repository | Unpinned. Each run clones the current `main` of `Cratis/AI`. `--source ` reads a local checkout instead, which is how you fix a known commit or work offline. | | `@cratis/pi` (npm) | Skills, rules, prompts, agents, a subagent tool, and quality hooks, inside Pi | Versioned per release. Pin and roll back by package version. | | Marketplace plugins (Claude Code, Codex, GitHub Copilot, Cursor) | Skills only | Unpinned. Read from the current default branch of `Cratis/AI`. | There is no separately published, per-profile versioned package, and `.cratis/ai.json` has no version field. The managed install records the source commit it used in `.cratis/ai.manifest.json`, so you can always see which revision a repository has, and `cratis ai status` shows whether a newer one is available. ``` git clone, or --source checkout left alone marketplace manifests Cratis/AI main cratis ai install / update .cratis/ai.json selection managed .cratis/ai content .cratis/ai.manifest.json source commit + hashes harness adapters mostly symlinks your own .cratis/ai files native plugins skills only npm release @cratis/pi ``` ## Selection, not a universal copy A repository selects its harnesses, profiles, and languages: ```json { "schemaVersion": "1.0", "harnesses": ["claude", "cursor", "opencode", "pi"], "profiles": ["cratis/application/csharp", "cratis/documentation"], "languages": ["csharp", "typescript"] } ``` The CLI resolves that selection against the corpus it reads. The `@cratis/pi` package reads the same file and exposes the matching skills; when the file is absent, it exposes every packaged skill. The marketplace plugins ignore the file and deliver every skill. ## Ownership and synchronization Cratis marks each managed text file with a `cratis-ai-managed` source identity and records source identities and installed hashes in `.cratis/ai.manifest.json`. The local corpus lives in `.cratis/ai`, and each harness adapter is a symbolic link or configuration entry that points there. On update: * new and changed upstream files are synchronized; * upstream files that moved or were removed are deleted only when their installed hash is unchanged; * a locally edited managed file stops update or uninstall before anything changes; * `--force` replaces or removes an edited managed file on purpose; and * files Cratis did not install, including your own `.cratis/ai` content, are left intact. If one of them sits where a Cratis file would go, the run reports a conflict and writes nothing. Use the lifecycle commands Run `cratis ai status` before updating; it exits non-zero when a managed file was edited, so it also works as a CI check. Run `cratis ai update --dry-run` to see what an update would change. Run `cratis ai uninstall` to remove unchanged Cratis-owned content and the harness configuration the CLI created. ## Keep project knowledge in the repository Shared guidance covers Cratis conventions and reusable workflows. Your repository owns the rest: architecture decisions, build commands, environments, approved secret mechanisms, fixtures. Put that in `.cratis/ai/rules/project.md` and its concern files, and add repository-specific skills under `.cratis/ai/skills/` with names that do not start with `cratis-`. No channel merges, overwrites, or removes them. Never copy `.cratis/ai`, `.claude`, `.github`, `.agents`, or `.pi` trees from one repository into another, and never hand-edit managed files. A copied tree goes stale without anyone noticing, and a hand edit blocks the next update. Change shared guidance in `Cratis/AI` through a reviewed pull request, after removing anything private or project-specific. ## Quality is verification Cratis does not gate corpus content on author identity, provenance records, or approval chains. The `Cratis/AI` CI checks corpus structure, profile composition, harness adapters, and the Pi package, and loads the corpus through Pi’s resource loader to prove skills, prompts, and extensions are discovered. A skill can carry a `verification.json` with an example input and assertions that the skill contains the guidance that input needs. These are static checks. They do not run a model, compile the skill’s code examples, or prove the guidance leads an agent to good results. That still takes a real task and a review of what the agent produced. ## Plugins and marketplaces Plugins are the quickest way to get Cratis skills into one tool. They are deliberately narrower than a repository install: the marketplace plugins carry no rules, no profile filtering, no ownership records, and no protection for local edits. Use a plugin for convenient skill access; use `cratis ai install` to configure a repository. [Updates and rollback](/ai/scenarios/updates-and-rollback/) shows how to preview, pin, and undo each channel. # API reference > Where to find the generated .NET and TypeScript API reference, and how it's produced. Use this page to find an exact API signature when a guide does not cover your case. The generated references describe documented public APIs at the version built by the site; check your installed package version when a signature differs. ## .NET / C\# The .NET API reference is generated with **DocFX** from the XML documentation comments across the Chronicle client SDK, Arc, and Fundamentals assemblies. **[Browse the .NET API reference →](/api/)** — public types and members from the Chronicle clients, Arc (including Arc.Core and MongoDB), and Fundamentals, organized by namespace. Shared extension classes list their contributing assemblies; existing per-library type and member URLs redirect to the canonical pages. XML comments also appear in IDE IntelliSense. Relevant NuGet packages include [`Cratis.Chronicle`](https://www.nuget.org/packages/Cratis.Chronicle), [`Cratis.Arc`](https://www.nuget.org/packages/Cratis.Arc), [`Cratis`](https://www.nuget.org/packages/Cratis), [`Cratis.Testing`](https://www.nuget.org/packages/Cratis.Testing), [`Cratis.Arc.MongoDB`](https://www.nuget.org/packages/Cratis.Arc.MongoDB), and [`Cratis.Specifications.XUnit`](https://www.nuget.org/packages/Cratis.Specifications.XUnit). Install only the packages your application needs. ## TypeScript The TypeScript API reference is generated with **TypeDoc** from the `@cratis/*` packages and surfaced alongside the rest of the site: * [`@cratis/arc`](/api/arc/javascript/arc/) — the Arc client core * [`@cratis/arc.react`](/api/arc/javascript/arc.react/) — React hooks and bindings * [`@cratis/arc.react.mvvm`](/api/arc/javascript/arc.react.mvvm/) — the MVVM layer * [`@cratis/arc.vite`](/api/arc/javascript/arc.vite/) — Vite integration for metadata and queries; the .NET build generates proxies * [`@cratis/fundamentals`](/api/fundamentals/javascript/) — shared utilities and concepts ## How it’s produced (for contributors) We deliberately **combine tooling**: a modern site for the narrative docs, and the best generator for each kind of API reference. The reference is built in the documentation pipeline from the product source — there’s nothing to hand-maintain. See the documentation site’s `README.md` for the build details. ## When to reach for what * **Learning or solving a problem?** Stay in the guides, [tutorial](/chronicle/tutorial/), and [scenarios](/chronicle/scenarios/) — they explain the *why* and the *how*. * **Looking up an exact signature?** The API reference and your IDE’s IntelliSense are the fastest path. # Arc > Commands, queries, validation, authorization, and generated TypeScript clients for ASP.NET Core, Spring Boot, and the TypeScript source preview. Arc ## CQRS application framework for ASP.NET Core, Spring Boot, and Node.js Arc is an opinionated CQRS application framework with commands, queries, validation, authorization, and TypeScript proxy generation. Write the backend in C#, Kotlin or Java, or the TypeScript source preview; each can generate a typed frontend client. Event sourcing is not required. ## Start here When commands, queries, validation, authorization, frontend contracts, and persistence each grow their own conventions, Arc gives them one application boundary. Start with current-state persistence or application services. Add Chronicle only where event sourcing fits, and add Components only where its React compositions fit. [ Start an Arc host](#start-an-arc-host) [Add Arc to an ASP.NET Core, Spring Boot, or TypeScript host.](#start-an-arc-host) [ Commands and queries](#commands-and-queries) [Execute changes, expose reads, and generate the client contracts for both.](#commands-and-queries) [ Frontend contracts](#frontend-contracts) [Use the generated TypeScript runtime directly or compose it with React.](#frontend-contracts) [ Persistence choices](#persistence-choices) [Keep current state in application services, MongoDB, or EF Core, or add Chronicle.](#persistence-choices) [Why Arc ](/arc/why-arc/)The problem Arc solves, why it leans on CQRS and proxy generation, and how it keeps a full-stack app type-safe end to end. [Coming from MediatR and MVC (C#) ](/arc/backend/csharp/coming-from-mediatr-and-mvc/)Map controllers, DTOs, and MediatR handlers onto Arc's command and query model. [Coming from Spring MVC (Kotlin and Java) ](/arc/backend/kotlin/coming-from-spring-mvc/)Map @RestController handlers and DTOs onto Arc's command and query model. [Samples ](/samples/)Runnable event sourcing and CQRS samples for the Cratis stack, including full Arc applications. ## Start an Arc host Arc runs on ASP.NET Core and Spring Boot; a third implementation for TypeScript on Node.js is a [source preview](/arc/backend/typescript/) with no published npm package or full parity yet. Choose a backend below and check its capabilities before relying on a shared example. The shared [HTTP contract](/arc/http-contract/) is verified for C# and JVM; TypeScript’s coverage is documented separately. [C# on ASP.NET Core ](/arc/backend/csharp/getting-started/)Install Cratis.Arc, configure the host, and reach the first command and query checkpoint. Arc.Core is a lighter alternative host. [Kotlin or Java on Spring Boot ](/arc/backend/kotlin/get-started/)Apply the io.cratis.arc Gradle plugin, declare a command and a read model, and call them. A Java walkthrough sits alongside it. [TypeScript source preview on Node.js ](/arc/backend/typescript/getting-started/)Build an Arc host from source; inspect the capability reference before using preview APIs. Starting the host verifies the setup path only. Commands, queries, generated proxies, persistence integrations, and frontend packages each have additional configuration and version requirements. ## Commands and queries Arc supports model-bound records and methods as well as controller-based endpoints. The command and query pipelines handle dependency injection, validation, authorization, filters, execution scopes, result conversion, and HTTP exposure in C# and on the JVM. TypeScript implements a subset of these capabilities; consult its [capability reference](/arc/backend/typescript/reference/capabilities/). The [scenario recipes](/arc/scenarios/) show the same task, such as [validating a command](/arc/scenarios/validate-a-command/), with backend examples where the capability is available. Queries can return one result or use Arc’s observable-query APIs. The TypeScript generator creates clients for recognized commands, queries, referenced types, validation rules, and identity details. See the detailed product documentation for the exact transport, subscription, and lifecycle behavior. [Command pipeline (C#) ](/arc/backend/csharp/commands/)Model intentions, validate and authorize them, provide dependencies, execute handlers, and convert typed results into HTTP responses. [Command pipeline (Kotlin and Java) ](/arc/backend/kotlin/guides/commands/)The same command model on Spring Boot: KSP-generated handlers, dependency resolution, validation and typed results. [Query pipeline (C#) ](/arc/backend/csharp/queries/)Expose purpose-shaped reads with paging, sorting, filtering, result rendering, and diagnostics. [Query pipeline (Kotlin and Java) ](/arc/backend/kotlin/guides/queries/)Model-bound read models with GET, RFC QUERY, HTTP snapshots, SSE and WebSocket hosting on Spring Boot. [Observable-query APIs (C#) ](/arc/backend/csharp/queries/observable-query-demultiplexer/)Work with documented query subscriptions, collection changes, subscription guards, and query health information. [Observable-query APIs (Kotlin and Java) ](/arc/backend/kotlin/guides/observable-queries/)Subscribe to collection changes over SSE or WebSocket, and the same observable-query lifecycle on the JVM host. ## Frontend contracts `@cratis/arc` supplies the TypeScript command, query, validation, identity, and messaging runtime used by generated proxies. `@cratis/arc.react` adds hooks, command forms, dialog infrastructure, identity composition, and query components. The composed `CommandDialog` used in the tutorial comes from the separate `@cratis/components` package. The optional React/MVVM package adds view models, dependency injection, route parameters, and browser abstractions. [Explore Arc’s frontend packages](/arc/frontend/) to choose the TypeScript, React, or React/MVVM path. Components is separate: applications may use Arc without Components, and may use the generated TypeScript clients without Arc’s React packages. ## Identity and tenancy Arc includes [pluggable identity and access behavior](/arc/understanding-identity-and-access/), identity-detail providers, server-side authorization and role checks, and frontend identity context. Tenant resolution can use a header, query parameter, claim, subdomain, fixed value, or development setting before provider-specific database or namespace mapping — see [tenancy on C#](/arc/backend/csharp/tenancy/) or [tenancy on Kotlin and Java](/arc/backend/kotlin/guides/ambient-tenancy/). Frontend visibility helpers do not replace server-side authorization. Tenant resolution also does not by itself establish isolation for an application or storage topology. ## Persistence choices Arc does not depend on Chronicle. Commands and queries can use application services or current-state persistence without an event log. Each backend integrates with the data access its own ecosystem already uses. [MongoDB (C#) ](/arc/backend/csharp/mongodb/)Arc's serialization conventions, tenant-aware database resolution, resilient collection access, and observable change streams. [Entity Framework Core (C#) ](/arc/backend/csharp/entity-framework/)Discover read models, configure value conversions and mappings, run migrations, and observe SQL Server, PostgreSQL or SQLite changes. [Spring Data (Kotlin and Java) ](/arc/backend/kotlin/guides/spring-data/)Inject JPA or MongoDB repositories into queries and commands, with Arc paging, tenant routing, transactions and observable change streams. [Chronicle (C#) ](/arc/backend/csharp/chronicle/)Add event-sourced commands, concurrency behavior, tenant namespaces and reactor side effects. [Chronicle (Kotlin and Java) ](/arc/backend/kotlin/guides/chronicle/)The same event-sourced commands, concurrency behavior, tenant namespaces and reactor side effects on Spring Boot. ## Inspection, contracts, and testing Arc can describe registered commands, queries, identity schemas, and OpenAPI operations at runtime. Repository packages also include analyzers, source generators, command-scenario testing, proxy-generation checks, Vite helpers, and Arc-specific ESLint rules. * [Inspect registered application contracts (C#)](/arc/backend/csharp/introspection/) — commands, queries and identity schemas are also introspectable on Kotlin and Java; a dedicated guide is not yet published * Generate OpenAPI descriptions: [C#](/arc/backend/csharp/open-api/) or [Kotlin and Java](/arc/backend/kotlin/guides/openapi/) * Test command behavior: [C#](/arc/backend/csharp/testing/), [Kotlin and Java](/arc/backend/kotlin/guides/testing/), or [TypeScript preview](/arc/backend/typescript/testing/) * Review analyzer rules: [C# Roslyn analyzers](/arc/backend/csharp/code-analysis/) or [Kotlin KSP diagnostics](/arc/backend/kotlin/reference/diagnostics/) Check the combination you use These are separate packages and integration surfaces. Their existence does not establish compatibility with every frontend, runtime, persistence provider, browser, or product version. Check the package manifests and the evidence for the combination you evaluate. ## Continue [ Arc source and README](https://github.com/Cratis/Arc) [Repository structure, package roles, contribution guidance, and current limits.](https://github.com/Cratis/Arc) [ Arc packages (C#)](https://www.nuget.org/packages/Cratis.Arc) [Published .NET package metadata and versions.](https://www.nuget.org/packages/Cratis.Arc) [ Arc packages (Kotlin and Java)](https://central.sonatype.com/namespace/io.cratis) [Published Maven Central package metadata and versions.](https://central.sonatype.com/namespace/io.cratis) [ Chronicle](/chronicle/) [The optional event-sourced runtime integration for Arc application behavior.](/chronicle/) [ Components](/components/) [Optional React components aligned with Arc application patterns.](/components/) # CQRS without event sourcing > Arc's CQRS model runs over current-state storage without Chronicle, and this page shows where CQRS ends and event sourcing begins. It’s easy to assume Arc and [Chronicle](/chronicle/) are a package deal, because they work well together. We think event sourcing is the default architecture for information systems, and Chronicle is the Cratis event-sourcing platform. But Arc itself is CQRS: **commands, queries, and generated TypeScript proxies that keep your React frontend in lockstep with your backend.** Where the data actually lives is a separate decision. This page shows Arc on its own — the same typed full-stack experience, backed by a plain database instead of an event log — so the line between CQRS and event sourcing is explicit. CQRS and event sourcing fit naturally together, but neither depends on the other. ## The line between Arc and Chronicle Arc is a layer that can sit *on top of* Chronicle; Chronicle never depends on Arc. That direction is the whole point — it’s why a bounded current-state slice can keep everything Arc gives you without storing events. Each backend has its own current-state integrations: [MongoDB](/arc/backend/csharp/mongodb/) and [Entity Framework](/arc/backend/csharp/entity-framework/) on C#, [Spring Data](/arc/backend/kotlin/guides/spring-data/) on JVM, and [MongoDB or Drizzle](/arc/backend/typescript/reference/capabilities/) in the TypeScript preview. Chronicle adds the optional event-sourced path. ``` Arc.Core — commands · queries · generated proxies MongoDB EF Core / SQL Chronicle — event sourcing ``` Pick a supported current-state store for your backend and you can build a typed CQRS app without an event log. Pick Chronicle and the same Arc boundary records facts, builds projections, and keeps history. ## A standalone slice, end to end Choose your backend The document-store examples and host excerpts below have tabs for each backend. The proxy-generation and testing guidance that follows is .NET-specific. For JVM setup and testing see [Spring Data read models](/arc/backend/kotlin/guides/spring-data/) and [testing](/arc/backend/kotlin/guides/testing/); for TypeScript see [getting started](/arc/backend/typescript/getting-started/) and [testing](/arc/backend/typescript/testing/). These **illustrative excerpts** show registering an author and listing authors live in MongoDB. For a runnable project, use [standalone .NET setup](/arc/backend/csharp/getting-started/) and its [backend checkpoint](/arc/backend/csharp/getting-started/your-first-command/), [Kotlin setup](/arc/backend/kotlin/get-started/), [Java setup](/arc/backend/kotlin/get-started/java/), or [TypeScript setup](/arc/backend/typescript/getting-started/) for domain declarations, host, database configuration, and replica-set bootstrap. The explanation here does not replace those prerequisites. **The read model is just a document.** Declare a read model and Arc exposes its query methods. The model-bound query returns a live stream: * C# ```csharp [ReadModel] public record Author(AuthorId Id, AuthorName Name) { // This static method is the query — served over HTTP, and live. public static ISubject> AllAuthors(IMongoCollection authors) => authors.Observe(); } ``` * Java ```java import org.springframework.data.mongodb.core.mapping.Document; @Document @ReadModel @AllowAnonymous public record Author(AuthorId id, AuthorName name) { public static Flow.Publisher> allAuthors(@FromServices MongoObservableQuery queries) { return queries.observePublisher(Author.class); } } ``` * Kotlin ```kotlin import org.springframework.data.mongodb.core.mapping.Document @Document @ReadModel @AllowAnonymous data class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic fun allAuthors(@FromServices queries: MongoObservableQuery): Flow> = queries.observe() } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel, service, type ObservableSource } from '@cratis/arc.core'; @readModel() export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource { return authors.observeAll(); } } ``` **The command writes the document directly.** Inject the database dependency and save. This example chooses no response payload; no event is involved: * C# ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } ``` * Java ```java @Command @AllowAnonymous public record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); } } ``` * Kotlin ```kotlin @Command @AllowAnonymous data class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; @command() export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @inject(AuthorRepository) handle(authors: AuthorRepository): Promise { return authors.save({ id: this.id, name: this.name }); } } ``` **A command can take the read model it acts on.** Mark the key on the command and configure the read model’s database lookup, so the command decides against current state instead of fetching it first. On the JVM MongoDB branch, `Author` must be a Spring Data-mapped `@Document` as well as an Arc `@ReadModel`; otherwise the MongoDB resolver cannot claim it from the mapping context at startup: * C# ```csharp using System.ComponentModel.DataAnnotations; [Command] public record RenameAuthor([property: Key] AuthorId Id, AuthorName NewName) { public Task Handle(Author author, IMongoCollection authors) => authors.ReplaceOneAsync(_ => _.Id == author.Id, author with { Name = NewName }); } ``` * Java ```java import io.cratis.arc.artifacts.CommandKey; @Command @AllowAnonymous public record RenameAuthor(@CommandKey AuthorId id, AuthorName newName) { public void handle(Author author, @FromServices AuthorRepository authors) { authors.save(new Author(author.id(), newName)); } } ``` * Kotlin ```kotlin import io.cratis.arc.artifacts.CommandKey @Command @AllowAnonymous data class RenameAuthor(@CommandKey val id: AuthorId, val newName: AuthorName) { suspend fun handle(author: Author, @FromServices authors: AuthorRepository) { authors.save(author.copy(name = newName)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, commandReadModel, inject, key } from '@cratis/arc.core'; @command() export class RenameAuthor { @field(AuthorId) @key() id!: AuthorId; @field(AuthorName) newName!: AuthorName; @inject(commandReadModel(Author), AuthorRepository) async handle(author: Author, authors: AuthorRepository): Promise { author.name = this.newName; await authors.save(author); } } // Configure Author as a MongoDB-owned read model; Arc will look it up by the declared key. ``` On .NET this works both for a MongoDB read model and for one carried by an Entity Framework `ReadOnlyDbContext`. On TypeScript, configure a `ReadModelForCommandResolver` for the keyed model (the MongoDB integration registers one for configured `readModels`). [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) covers what a nullable parameter means, and [Read models from other providers](/arc/backend/csharp/chronicle/read-models/other-providers/#declaring-the-key-without-chronicle) how to declare the key when it is not a single property. Separate the decision from the write These direct collection calls remain supported. On C# and TypeScript, for immediate inline work that a model-bound command can describe before execution, prefer returned command operations ([C#](/arc/backend/csharp/commands/operations/), [TypeScript](/arc/backend/typescript/commands/operations/)). The JVM backend has no operation mechanism; there, perform the work in `handle` through an injected service. With operations, the handler declares the write and Arc performs it afterward, without requiring Chronicle. Optional compensation is best-effort and in-process, not atomic database rollback or durable recovery. The [migration recipe](/arc/backend/csharp/commands/operations/migrating/) shows how to preserve an existing response contract. That’s the backend behavior. Start the host with the database configured as in the backend-specific setup guides. The .NET excerpt assumes `Cratis:MongoDB`; Arc auto-discovers collections and serializers rather than requiring per-model registrations. For the TypeScript excerpt, place the `AuthorRepository` abstract class shown below in `Features/Authors/AuthorRepository.ts` and import it into the command, query, validators, and host; it is the runtime token used by `@inject` and `service`, not a type-only interface. Import your `Author`, `AuthorId`, `AuthorName`, `RegisterAuthor`, and `RenameAuthor` declarations from their feature files. Keep the TypeScript [setup guide’s `generate.mjs`](/arc/backend/typescript/getting-started/create-an-application/#generate-the-metadata) on `--project ./tsconfig.json` and `--artifacts ./Features`; import its generated metadata and discover the same `Features/` root in the host. Regenerate after chapter 2 adds validators, or the host will not register them. Install `@cratis/arc.mongodb`, set `MONGODB_URI` (or use the loopback default), and use a replica set for live MongoDB change streams. The scoped `MongoAuthorRepository` supplies the persistence methods used in the preceding excerpts: * C# ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(); builder.UseCratisMongoDB(); var app = builder.Build(); app.UseCratisArc(); app.Run(); ``` * Java ```java import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class LibraryApplication { public static void main(String[] args) { SpringApplication.run(LibraryApplication.class, args); } } ``` * Kotlin ```kotlin import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication class LibraryApplication fun main(args: Array) { runApplication(*args) } ``` * TypeScript (preview) ```typescript import { ArcApplication, type ObservableSource } from '@cratis/arc.core'; import { mongoCollection, type MongoCollection } from '@cratis/arc.mongodb'; import type { Document, Filter } from 'mongodb'; import { metadata } from './Features/generatedMetadata.js'; // Features/Authors/AuthorRepository.ts — import Author and its concepts here. // Import this runtime token into main.ts, commands, queries, and validators. export abstract class AuthorRepository { abstract save(author: Author): Promise; abstract all(): Promise; abstract observeAll(): ObservableSource; abstract findById(id: AuthorId, signal?: AbortSignal): Promise; abstract existsByName(name: AuthorName, signal?: AbortSignal): Promise; } // main.ts — import AuthorRepository from ./Features/Authors/AuthorRepository.js. // Also import Author, AuthorId, AuthorName, RegisterAuthor, and RenameAuthor from their feature files. class MongoAuthorRepository extends AuthorRepository { constructor(private readonly collection: MongoCollection) { super(); } async save(author: Author): Promise { const document = this.collection.codec.serialize(author); await this.collection.native.replaceOne({ _id: document._id } as Filter, document, { upsert: true }); } all(): Promise { return this.collection.find(); } observeAll(): ObservableSource { return this.collection.observe(); } async findById(id: AuthorId): Promise { return await this.collection.findById(id) ?? undefined; } async existsByName(name: AuthorName): Promise { return (await this.collection.find({ [this.collection.codec.fieldName('name')]: name.value })).length > 0; } } export async function start(): Promise { const builder = ArcApplication.createBuilder({ tenancy: { resolve: () => 'default' } }); builder.useGeneratedMetadata(metadata); builder.withMongoDB({ server: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017', database: 'Library', readModels: [Author] }); builder.services.addScoped(AuthorRepository, async scope => new MongoAuthorRepository(await scope.resolve(mongoCollection(Author)))); // Use the same Features root as arc-proxygenerator's --artifacts argument. await builder.discover(new URL('./Features/', import.meta.url)); const app = await builder.build(); await app.run({ port: 3000 }); } await start(); ``` On .NET, install `Cratis.Arc.ProxyGenerator.Build`, configure `CratisProxiesOutputPath`, and build in Debug as shown in setup to generate `RegisterAuthor` and `AllAuthors`. Building with Arc alone does not generate them. On the JVM, run `generateArcProxies`; on TypeScript, run your application’s source-based generator script and regenerate metadata after changing artifacts. This React **fragment** assumes the [frontend setup](/arc/frontend/react/getting-started/) imports/providers and the [per-operation `id` state](/arc/tutorial/first-slice/) (`Guid.create()` from `@cratis/fundamentals`), with a fresh dialog instance for each registration: ```tsx const [authors] = AllAuthors.use(); // live — re-renders when the collection changes command={RegisterAuthor} title="Add author" initialValues={{ id }}> value={i => i.name} title="Name" /> ``` Note `AllAuthors` is live with no event sourcing involved. On C#, `IMongoCollection.Observe()` watches MongoDB’s change stream; the JVM and TypeScript tabs use their own observable sources. Subscribed browsers update when the configured source emits. [Observed DbSets](/arc/backend/csharp/entity-framework/observing/) receive same-host `SaveChanges` notifications through Arc’s configured interceptor. SQLite does not detect arbitrary external-process writes; other EF providers need their database notification setup for that. ## Test the slice without Chronicle On C#, Arc’s command testing does not depend on Chronicle either. Start with `Cratis.Arc.Testing`, drive the command through a `CommandScenario`, and assert the `CommandResult` exactly as you would in an event-sourced slice. The only difference is what you assert after the command runs: a current-state slice checks the database or application service it wrote to, while a Chronicle-backed slice can also assert the appended events. That keeps the CQRS boundary testable before you decide whether the slice needs an event log. See [Arc testing](/arc/backend/csharp/testing/) and [command scenarios](/arc/backend/csharp/testing/command-scenario/) for the base testing loop; add the [Chronicle testing extension](/arc/backend/csharp/testing/chronicle/) only when the command appends events. ## What actually changes when you add Chronicle For C#, set this slice next to the same slice with [Chronicle added later](/arc/backend/csharp/chronicle/add-event-sourcing/). The TypeScript Chronicle integration is experimental; do not assume the same migration procedure applies. You can preserve the query and frontend **contracts**, while replacing direct writes with events and configuring a projection. The provider lookup/observation implementation may need to change; adding a package does not migrate existing data automatically: | | Standalone (this page) | With Chronicle | | --- | --- | --- | | What the handler does | inserts a document | appends an event | | What fills the read model | the command, directly | a projection over the event | | What you can read | current state | current state **and full history** | Adopting Chronicle changes persistence and adds projection/bootstrap work. If you preserve the query’s contract and route, its generated caller and screen can remain unchanged. ## The trade-off Storing current state directly is simpler for bounded CRUD surfaces, reference data, settings, and adoption steps. What you don’t get is everything an event log buys you: an audit trail, the ability to rebuild a read model a brand-new way from history, temporal queries, and reactors that fire on facts. For information systems, we prefer starting with Chronicle because those needs show up often. [Why Event Sourcing](/chronicle/why-event-sourcing/) explains the default. The reassuring part, from the table above: the boundary stays clean. If a direct-database slice later belongs in the event-sourced model, move persistence to Chronicle and deliberately preserve the public read contracts so the frontend need not change. [Adopting Cratis](/adopting-cratis/) walks through doing exactly that, one step at a time. ## Go deeper * [MongoDB integration](/arc/backend/csharp/mongodb/) — setup, serializers, class mapping, and [observing collections](/arc/backend/csharp/mongodb/observing-collections/) for live queries. * [Entity Framework integration](/arc/backend/csharp/entity-framework/getting-started/) — DbContexts, read-only contexts, and [observing DbSets](/arc/backend/csharp/entity-framework/observing/). * [Commands](/arc/backend/csharp/commands/) and [Queries](/arc/backend/csharp/queries/) — the full model-bound and controller-based reference. * [Why Arc](/arc/why-arc/) — the problem Arc solves, and how CQRS relates to event sourcing. # Backend > Choose the Arc backend implementation for your language and compare shared concepts with capability-specific behavior. Arc’s backend gives commands, queries, validation, authorization and proxy generation one application boundary. That shape is the same whichever language you write it in — a command is a declared intention that is validated, authorized and handled, and a query is a purpose-shaped read. What differs is the host you run it on and the API surface you write against. ## Pick your language * [C#](/arc/backend/csharp/) — ASP.NET Core and the lightweight Arc.Core host, with MongoDB and Entity Framework Core integrations, Roslyn analyzers, and post-build TypeScript proxy generation. * [Kotlin and Java](/arc/backend/kotlin/) — Spring Boot, with Spring Data JPA and MongoDB integrations, KSP compile-time diagnostics, and Gradle-driven TypeScript proxy generation. * [TypeScript](/arc/backend/typescript/) — Node.js, on its own HTTP host or in Express, Fastify, or Hono, with MongoDB and Drizzle SQL integrations, an experimental Chronicle integration, ESLint rules, and TypeScript proxies generated from your source. It is a source preview: no package is published to npm and it does not have full parity with the C# implementation. Its [capability reference](/arc/backend/typescript/reference/capabilities/) gives the status of each capability. ## What the implementations share These implementations share the application-boundary model and frontend packages. Wire compatibility is capability-specific; implementation references document supported behavior and deliberate differences. The [HTTP contract](/arc/http-contract/) describes the shared contract verified for C# and JVM, while the TypeScript [capability reference](/arc/backend/typescript/reference/capabilities/) identifies what its source preview supports. ## The frontend is shared Every backend generates proxies for the same `@cratis/arc` and `@cratis/arc.react` packages, so the [frontend documentation](/arc/frontend/) applies whichever backend you chose. The steps for running the generator differ by language; see your backend’s proxy generation page. # Backend > How you express commands and queries as plain records in C# and let Arc handle endpoints, validation, authorization, and proxies. The backend is where you express *what your application does* — the commands that change state and the queries that read it. Arc’s job is to make that expression the only thing you write: you define a command or query as a plain record, and Arc handles the HTTP endpoint, validation, authorization, and a typed TypeScript proxy for the frontend. CQRS without the ceremony. In the full Cratis loop those commands append events through Chronicle; in bounded current-state slices they can write MongoDB or EF Core directly. ``` Arc discovers compiled assembly/PDB · configured post-build tool commands write queries read Command / query record HTTP endpoint Typed TS proxy MongoDB / EF Core Read models ``` ## Start here New to the backend? Walk through [Getting started](/arc/backend/csharp/getting-started/) to build your first command and query end to end. Then the two pillars: * [Commands](/arc/backend/csharp/commands/) — intents that change state through `Handle()`. * [Queries](/arc/backend/csharp/queries/) — reads, exposed to the frontend as typed proxies (including live, observable ones). The magic that ties it to the frontend is [Proxy generation](/arc/backend/csharp/proxy-generation/) — read it early; it’s why the whole stack is type-safe. ## Integrations Arc meets the rest of your stack: | Topic | What it covers | | --- | --- | | [MongoDB](/arc/backend/csharp/mongodb/) | Document storage for read models and other data. | | [Entity Framework](/arc/backend/csharp/entity-framework/) | EF Core integration for relational read models. | | [Chronicle](/arc/backend/csharp/chronicle/) | Event sourcing integration — append events from commands, build read models, feed state back into business rules. | | [ASP.NET Core](/arc/backend/csharp/asp-net-core/) | How Arc plugs into the ASP.NET Core pipeline. | ## Cross-cutting | Topic | What it covers | | --- | --- | | [Core](/arc/backend/csharp/core/) | Commands, queries, and dependency injection at the lower level. | | [Identity](/arc/backend/csharp/identity/) | Who the user is — authentication and identity details. | | [Tenancy](/arc/backend/csharp/tenancy/) | Tenant selection and provider-specific storage routing; membership authorization is separate. | | [Open API](/arc/backend/csharp/open-api/) | OpenAPI/Swagger generation. | | [Code Analysis](/arc/backend/csharp/code-analysis/) | Analyzers and fixers that catch mistakes at compile time. | Building the UI on top? Head to the [frontend](/arc/frontend/), which consumes everything here through the generated proxies. # Application calendar > Resolve the time zone that applies to an operation and re-evaluate live answers when the local date changes. The application calendar answers one question: which time zone’s calendar applies to this operation? It pairs an instant you supply with that zone, so code can ask for the local date without reading the clock itself. ## Configure the application time zone Set the zone under `Cratis:Arc:Calendar:TimeZone`, or in code: ```json { "Cratis": { "Arc": { "Calendar": { "TimeZone": "Europe/Oslo" } } } } ``` The default is `UTC`. Only IANA ids are accepted. When the host starts, an unknown id, a Windows-only id such as `W. Europe Standard Time`, and a non-zero fixed-offset id such as `Etc/GMT+5` all throw `TimeZoneIsNotAnIanaZone`, which names the id. Arc never replaces a zone it cannot resolve with UTC, because that would move every local date without anyone noticing. A zone set in code is applied after configuration binding, so it wins over the bound value. ## Zone precedence `IApplicationCalendar.ResolveZone` picks the first zone that exists: 1. The zone the operation declares. 2. The tenant’s zone, from the first registered `ICanProvideTenantCalendar` that returns an id. 3. The application zone. Only the operation, a provider you register, and the configuration can choose a zone. A request header or query parameter never does. A provider that returns an unusable id throws `TimeZoneIsNotAnIanaZone`, and a provider that throws propagates the exception. ```csharp using Cratis.Arc.Calendar; using Cratis.Arc.Tenancy; public class TenantCalendarFromSettings : ICanProvideTenantCalendar { public ValueTask Provide(TenantId tenant, CancellationToken token) => new(tenant.Value == "acme" ? "Asia/Tokyo" : null); } ``` ## Read an instant `IApplicationCalendar.Read` returns a `ClockReading`. It never reads a clock; you pass the instant. ```csharp ClockReading reading = await calendar.Read(DateTimeOffset.UtcNow, tenant, null, token); DateOnly today = reading.Today; DateOnly yesterday = reading.Today.AddDays(-1); DateOnly sameInstantYesterday = reading.DateOf(reading.Instant.AddDays(-1)); ``` `Today.AddDays(-1)` steps calendar days. `DateOf(Instant.AddDays(-1))` steps elapsed time, which differs across a daylight-saving change. ## Re-evaluate at clock boundaries An answer such as “items due today” changes when the local date changes, even though no data changed. `ReevaluateAtBoundaries` takes a subject of candidates and re-evaluates the latest candidates whenever they change and whenever a boundary passes. ```csharp var context = await clockBoundaries.Begin(tenant, null, token); ISubject> answer = candidates.ReevaluateAtBoundaries( context, (items, reading) => new ClockEvaluation>( [.. items.Where(item => item.Due <= reading.Today)], LocalDateChanges.NextLocalDateChange(reading.Zone, reading.Instant))); ``` `LocalDateChanges.NextLocalDateChange` handles a local midnight, a gap that skips midnight, a wholly skipped date, and a transition that moves the local date back. The next boundary of an evaluation must be after `reading.Instant`, or `null` when the result cannot change because of the clock. A boundary that is not after it ends the subject with a `ClockBoundaryIsNotInTheFuture` error instead of re-evaluating in a loop. An evaluation whose result equals the last emitted result is not emitted, but only inside this operator. The first evaluation is always emitted. Collections are compared positionally, so a reorder is a change. Evaluations run one at a time inside the tenant of the context. The subject connects on its first subscription. It releases its timer and its subscription to the candidates when the last subscriber leaves, the context’s token is cancelled, or the stream errors or completes. Calling `OnNext` on it is not supported. ## Subscription lifetime The zone is resolved once, when `IClockBoundaries.Begin` runs, and is fixed for the subscription. Changing the application zone needs a restart, and a changed tenant zone applies to new subscriptions. ## Test with a fake clock The operator arms its timers on the registered `TimeProvider`, so a test can use `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` and `Advance` past a boundary. ## Platform prerequisites Zone resolution uses the host’s time-zone data. Containers need `tzdata`, and the host must not run in a configuration that lacks time-zone data. In invariant-globalization mode zone ids still resolve, but verify that your image carries the data. A host without it fails at startup rather than falling back. ## Out of scope A live `now` that ticks continuously, and an advanceable clock for query scenarios, are not part of this feature. # ASP.NET Core Integration > What the Cratis.Arc package adds for ASP.NET Core hosts, and where to find configuration, authorization, and integration guides. The Arc provides enhanced capabilities for ASP.NET Core applications, building upon the core Arc features with web-specific functionality. Install `Cratis.Arc` for this host (`Cratis.Arc.Core` alone is the lightweight alternative). Register with `WebApplicationBuilder.AddCratisArc()` and activate with `WebApplication.UseCratisArc()`. No event store is required. OpenAPI/Swagger have separate optional packages. ## Features * **[Configuration](/arc/backend/csharp/asp-net-core/configuration/)** - Configure Arc through appsettings.json or programmatically * **[Authorization](/arc/backend/csharp/asp-net-core/authorization/)** - Arc role checks versus separately configured ASP.NET endpoint policies * **[Microsoft Identity](/arc/backend/csharp/asp-net-core/microsoft-identity/)** - Integration with Microsoft Client Principal for Azure services * **[FromRequest Attribute](/arc/backend/csharp/asp-net-core/from-request/)** - Advanced model binding combining multiple HTTP request sources * **[Swagger](/arc/backend/csharp/asp-net-core/swagger/)** - Enhanced OpenAPI documentation with Arc-specific schema generation * **[Validation](/arc/backend/csharp/asp-net-core/validation/)** - Comprehensive validation with FluentValidation support * **[Without Wrappers](/arc/backend/csharp/asp-net-core/without-wrappers/)** - Control response wrapping behavior for specific endpoints * **[Invariant Culture](/arc/backend/csharp/asp-net-core/invariant-culture/)** - Configure invariant culture defaults and understand request-level overrides ## When to Use ASP.NET Core Integration Use the ASP.NET Core integration when you need: * Full web framework capabilities (Kestrel, middleware pipeline, static files) * Razor views or MVC features * Swagger UI for API documentation * Advanced middleware scenarios * Kestrel’s HTTP server capabilities * Traditional web application patterns ## When to Use Arc.Core Instead Consider using [Arc.Core](/arc/backend/csharp/core/overview/) (without ASP.NET Core) when you need: * Minimal dependencies and smaller binary size * A simplified `HttpListener` host rather than the ASP.NET middleware ecosystem * A deployment whose performance and Native AOT compatibility you verify with your actual dependencies * Console applications or background services * Scenarios where full web framework is unnecessary ## See Also * [Arc.Core Overview](/arc/backend/csharp/core/overview/) - Lightweight alternative without ASP.NET Core * [Commands](/arc/backend/csharp/commands/) - Command handling patterns * [Queries](/arc/backend/csharp/queries/) - Query patterns and conventions * [Tenancy](/arc/backend/csharp/tenancy/) - Tenant isolation and management # Authorization in ASP.NET Core > Evaluate named policies and authentication schemes through the ASP.NET Core Arc host. When your application uses `Cratis.Arc`, two layers can reject a request: ASP.NET Core middleware at the HTTP boundary and Arc filters in the command/query pipelines. Keep their configuration separate so a policy tested through HTTP is not mistaken for an in-process guarantee. ## The two boundaries | Boundary | Checks | Applies to | | --- | --- | --- | | ASP.NET Core authorization | Microsoft endpoint/controller metadata, registered policies, schemes, default/fallback policy | Requests that traverse the configured middleware and matching endpoint metadata | | Arc authorization filters | Authentication, roles, named Arc or ASP.NET Core policies, and selected authentication schemes | Commands and queries executed through Arc pipelines | `Cratis.Arc.Authorization.RolesAttribute` derives from **Arc’s** `AuthorizeAttribute`, not Microsoft’s. Every authorization attribute on a declaration applies, so stacked attributes require all of them, as they do in ASP.NET Core. A policy name must resolve to exactly one registered Arc or ASP.NET Core policy; a missing or ambiguous policy fails startup. With `Cratis.Arc`, Arc’s pipeline also enforces **Microsoft’s** `[Authorize]` and `[AllowAnonymous]` on model-bound commands and read models - authentication, roles, policies, and schemes, as it does for its own attributes. This check also runs for mapped HTTP commands/queries and new hub subscriptions, which ASP.NET Core middleware cannot protect on its own. Direct calls with no identity transition retain their existing scope; a scheme-selected direct call must use a safe Arc-owned execution scope, as described below. Both attribute families can be mixed; a method’s declaration replaces its type’s whichever family each comes from, and a declaration that is both anonymous and restricted is rejected as ambiguous. Arc’s own attributes remain the portable choice: they are enforced on every Arc host, including Arc Core’s own HTTP host, where nothing reads the Microsoft ones. Arc v18.2.0 through v22.20.0 did not enforce the Microsoft attributes When Arc Core gained its own attributes in v18.2.0, the ASP.NET Core evaluators meant to keep honoring Microsoft’s resolved Arc’s same-named attributes instead, so a model-bound command protected only with Microsoft’s `[Authorize]` was open to every caller. Upgrade, or replace those attributes with `Cratis.Arc.Authorization`’s. ## Setup Configure a real authentication scheme for your deployment before adding the middleware. The following **configuration fragment** belongs in an existing `WebApplication` program after authentication service registration: ```csharp using Cratis.Arc; using Microsoft.AspNetCore.Authorization; builder.AddCratisArc(); builder.Services.AddAuthorization(options => options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build()); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.UseCratisArc(); app.Run(); ``` See [Microsoft Identity integration](/arc/backend/csharp/asp-net-core/microsoft-identity/) for one authentication option and its trusted-ingress prerequisites. A fallback policy is not a substitute for authentication service configuration. ## Protecting all endpoints by default ASP.NET’s `AuthorizationOptions.FallbackPolicy`, configured above with `builder.Services.AddAuthorization(...)`, applies to HTTP endpoints without applicable authorization metadata, including MVC controller actions. Its default policy applies when Microsoft `[Authorize]` supplies no named policy. Explicit anonymous metadata bypasses these policies. The observable hub routes remain anonymous, but the hub evaluates `FallbackPolicy` separately for every non-anonymous query subscription before the query executes. It authenticates any schemes named by the fallback policy against the live WebSocket upgrade or SSE subscribe POST, then evaluates the policy with that `HttpContext` as its resource. Queries allowing anonymous access or opted-in guest policy evaluation bypass the host fallback, matching their mapped direct endpoints. A denial or policy failure produces an `Unauthorized` frame, and SSE subscribe returns 401. This host check does not add a fallback policy to in-process pipeline calls. Arc’s [`IFallbackAuthorizationEvaluator`](/arc/backend/csharp/core/authorization/#set-baseline-requirements-without-overriding-anonymous-access) is separate: it supplies baseline requirements for model-bound commands and queries on both Arc hosts, and for controller-based queries reached through Arc’s observable hub path in the ASP.NET Core host. It also covers server-side pipeline invocations that never pass through ASP.NET middleware. Direct HTTP GET and WebSocket requests to controller-based queries are MVC actions, not covered by `IFallbackAuthorizationEvaluator`. Use ASP.NET Core’s `FallbackPolicy` to require authorization for those actions and controller-based commands without explicit authorization metadata. Caution Normal Arc activation maps the discovery endpoints (command/query introspection, development-user/tenant discovery and the identity schema) anonymously only in Development; elsewhere they require an authenticated caller unless `ArcOptions.Introspection.RequireAuthentication` is `false`. A fallback policy does **not** protect endpoints that are explicitly anonymous. Review [production discovery exposure](/arc/backend/csharp/introspection/#production-access). ## Role-based authorization For model-bound commands and queries, use explicit Arc imports. These are complete type fragments for an existing Arc host; neither needs a database or Chronicle: ```csharp using Cratis.Arc.Authorization; using Cratis.Arc.Commands.ModelBound; [Roles("Admin", "Manager")] [Command] public record ReviewRequest(Guid RequestId) { public Guid Handle() => RequestId; } ``` Arc requires authentication and at least one listed role. The returned `Guid` remains ordinary response data. ```csharp using Cratis.Arc.Authorization; using Cratis.Arc.Queries.ModelBound; [ReadModel] public record ServiceStatus(string State) { [Roles("Admin", "Auditor")] public static ServiceStatus Internal() => new("Running"); } ``` Arc query method attributes take precedence over read-model type attributes. For the full Arc rules, see [Core authorization](/arc/backend/csharp/core/authorization/). ## Authorization in controllers For ASP.NET controller policy enforcement, use **Microsoft** attributes explicitly. This complete controller fragment assumes MVC is registered and controllers are mapped in the host: ```csharp using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/reports")] [Authorize(Roles = "Manager")] public class ReportsController : ControllerBase { [HttpGet] public IActionResult Get() => Ok(new { State = "Available" }); } ``` Microsoft controller/action requirements compose according to ASP.NET Core rules; do not transfer Arc’s method-overrides-type rule to them. See [ASP.NET Core authorization](https://learn.microsoft.com/aspnet/core/security/authorization/introduction). ## Policy-based authorization Register ASP.NET Core policies before building the host. Arc also evaluates these policies on model-bound artifacts reached through mapped HTTP endpoints and hub subscriptions, independently of middleware. This is a **configuration fragment** in an existing ASP.NET host: ```csharp builder.Services.AddAuthorizationBuilder() .AddPolicy("ReportsReader", policy => policy .RequireAuthenticatedUser() .RequireClaim("permission", "reports:read")); ``` After building the app and enabling its authentication/authorization middleware: ```csharp app.MapGet("/reports/availability", () => new { Available = true }) .RequireAuthorization("ReportsReader"); ``` This protects that HTTP endpoint. To protect a model-bound command or query through **every** Arc route instead, annotate its type or query method with `[Cratis.Arc.Authorization.Authorize(Policy = "ReportsReader")]` or Microsoft’s `[Authorize(Policy = "ReportsReader")]`. Arc evaluates the named policy inside its pipeline; it never assumes middleware has run. ASP.NET Core policy handlers receive the Arc `CommandContext` or `QueryContext` as their resource, not an MVC action resource. For an Arc pipeline invocation, that resource’s `ReceivedAt` is fixed at Arc dispatch before binding and scheme authentication; it survives the scheme-selected service scope. It is not network arrival time or a timestamp from before application middleware. MVC authorization is a separate boundary and does not use this Arc resource. Arc requires authentication before evaluating ASP.NET Core policies by default. For a policy that intentionally permits guests, register it with ASP.NET Core and then opt it in for Arc pipeline evaluation with `builder.Services.AddArcAnonymousAspNetAuthorizationPolicy("PublicOrMember")`. The policy must not call `RequireAuthenticatedUser()` (or include `DenyAnonymousAuthorizationRequirement`) or specify `AuthenticationSchemes`: a scheme-selected policy cannot evaluate a guest. Arc asks the ASP.NET Core policy provider for the opt-in name at startup and rejects missing, ambiguous, native Arc, authentication-required, or scheme-selected policies. The opt-in name must match the name in the command or query’s policy declaration exactly, including case. Even if a provider resolves names without regard to case, an opt-in for `Guest` does not opt in `guest`. The standalone Core host cannot use this ASP.NET Core opt-in. Only declarations made entirely of opted-in policies without roles or authentication schemes can evaluate guests; the policies still decide whether to grant access. For mapped model-bound command and query endpoints, Arc emits anonymous HTTP metadata only when the entire effective declaration qualifies for guest evaluation. This lets guests reach the Arc policy even with an authentication-requiring `FallbackPolicy`; command validation and query GET/QUERY endpoints use the same rule. The Arc pipeline still evaluates every policy and returns 403 on denial without running command/query logic. Do not combine the policy declaration with `[AllowAnonymous]`. Ordinary policies, mixed guest/ordinary requirements, roles, and authentication schemes retain their existing HTTP authentication behavior. The opt-in does not change MVC authorization or unrelated endpoints. An attribute’s `AuthenticationSchemes` and any schemes contributed by the policy select the identities used for authentication, role checks, and policy evaluation. Arc calls ASP.NET Core’s authentication service for each named scheme and combines the authenticated identities from successful schemes. A failed scheme does not cancel another scheme’s success; if none authenticates, Arc denies access rather than falling back to the default principal. For a selected-scheme request, Arc runs the command or query in a fresh execution service scope; both `ICurrentPrincipalAccessor` and `IHttpContextAccessor.HttpContext.User` expose the selected identity while it executes. The HTTP accessor presents an operation-local native context: its selected `User` and `RequestServices` do not replace those on the shared connection object, and unrelated request features still come from the live request. Existing HTTP accessor registrations made **before** Arc is added are decorated, retaining their ordinary no-scheme behavior; replacing that registration **after** Arc disables safe scheme-selected execution and Arc rejects it rather than mutating a shared request. The built-in Arc ASP.NET request adapter also keeps selected `User` and `RequestServices` operation-local; a custom `IHttpRequestContext` that shares a native request should implement `IAuthorizationRequestContext` to avoid changing that shared object during identity selection. The prior request services, principal, and tenant cache are restored afterward. Arc rejects a scheme-selected invocation through an arbitrary caller-supplied `IServiceProvider`: an already-resolved tenant-bound service cannot be safely rebound. Use mapped HTTP/hub entry points, or the command pipeline’s scope-owning `Execute(command)` overload from a live HTTP request. Queries with explicit schemes must enter through mapped HTTP or the hub, not a direct `IQueryPipeline.Perform(..., serviceProvider)` call. A call without a live HTTP context cannot satisfy a named scheme. A nested server-side execution using `BeginScope` and no named scheme remains supported; changing to a different scheme-selected identity after an outer command has bound a unit of work is rejected before joining that work. Configure and register every scheme before startup. On SSE, the live subscribe POST supplies the identity, not the older stream GET. Direct observable SSE/WebSocket emissions retain the live request and its selected identity, tenant, and execution provider until the stream ends; an ordinary no-scheme direct stream keeps the original request context and principal subtype. Hub emissions after subscription admission use the captured Arc principal and tenant, but deliberately expose **no native `HttpContext`**: the subscribe POST may already be disposed, and the emitting thread may belong to another user. Use `ICurrentPrincipalAccessor`, the Arc request context, and the emission context’s provider in hub guards/interceptors instead of `IHttpContextAccessor` for those later emissions. Custom host runtimes can implement `IAuthorizationEmissionRuntime` to capture a live direct request and suppress unavailable native HTTP in hub emissions. Policy checks gate admission, not revocation. See [observable emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/) for revocation after admission. To register a portable scoped policy instead, use [Core named policies](/arc/backend/csharp/core/authorization/#register-a-named-policy). Register a name in either the native Arc registry or ASP.NET Core, not both. ## Authorization results for commands and queries Arc authorization failures return results with `IsAuthorized = false` and skip the handler/query logic. Mapped Arc results use HTTP 403; ASP.NET middleware can challenge or forbid before Arc executes and may return a different response shape. An ordinary call to `ServiceStatus.Internal()` bypasses the query pipeline and **does not evaluate attributes**. Calling a command’s `Handle()` directly likewise bypasses authorization, validation, filters, and result handling. Use the [query pipeline](/arc/backend/csharp/queries/query-pipeline/), [command pipeline](/arc/backend/csharp/commands/command-pipeline/), or mapped HTTP endpoint when those guarantees matter. ## Custom authorization A command filter denying permission returns `CommandResult.Unauthorized(context.CorrelationId)`; a query filter uses `QueryResult.Unauthorized(context.CorrelationId)`. `CommandResult.Error(...)` describes an exception result, not an authorization rejection. See [command filters](/arc/backend/csharp/commands/command-filters/) for filter contracts. Read the trusted principal through `ICurrentPrincipalAccessor` (`Cratis.Arc.Authorization`), or `IHttpContextAccessor.HttpContext.User` when intentionally writing ASP.NET-specific code. `IProvideIdentityDetails` composes frontend details; returning roles or `IsUserAuthorized` in its payload does not add claims or authorize every command/query. The [identity details](/arc/backend/csharp/identity/identity-provider-service/) are presentation data and must not be used as authorization evidence. ## Verification checklist Test anonymous, authenticated-without-role, and permitted-role callers through both HTTP and direct pipelines where the latter are supported. Where HTTP policies or schemes matter, test wrong-policy/wrong-scheme principals against the actual endpoint and verify scoped collaborators use the selected tenant. Include discovery endpoints with Production settings and a fallback policy enabled. Test custom ownership and tenant membership checks independently of UI visibility. ## See also * [Core authorization](/arc/backend/csharp/core/authorization/) — host-independent attribute and principal contracts. * [Identity](/arc/backend/csharp/identity/) — identity enrichment and its trust boundary. * [Microsoft Identity](/arc/backend/csharp/asp-net-core/microsoft-identity/) — authentication setup. * [Tenancy](/arc/backend/csharp/tenancy/) — selection is not membership authorization. * [Proxy generation](/arc/backend/csharp/proxy-generation/) — clients transport results; they do not enforce server permissions. # Configuration > Configure Arc in an ASP.NET Core host through appsettings.json or code, including route generation and JSON settings. Cratis Arc can be configured both through `appsettings.json` and programmatically to customize its behavior. The main configuration is handled through the `ArcOptions` class. This page covers the ASP.NET Core host specifically. Code blocks are configuration/service fragments for an existing `Cratis.Arc` web project; import `Cratis.Arc`, `System.Text.Json`, and `Microsoft.Extensions.Options` as used. Types such as `MyCustomIdentityDetailsProvider` and `MyCustomConverter` are application-owned examples, not built-in Arc types. For the lightweight Arc.Core host and its listen URL configuration, see [Arc.Core Getting Started](/arc/backend/csharp/core/getting-started/). ## Default Configuration Section By default, Arc looks for configuration under the `Cratis:Arc` section in your `appsettings.json` file. The same keys can be supplied through environment variables — .NET maps the `__` separator onto nested keys, so `Cratis:Arc:GeneratedApis:RoutePrefix` becomes `Cratis__Arc__GeneratedApis__RoutePrefix`. ## Configuration Options ### Configuration example Here’s an example covering the most common options, bound from `appsettings.json` under `Cratis:Arc`. The [complete options tree](/arc/backend/csharp/configuration/#the-arcoptions-tree) also documents `ExposeExceptionDetails`, `Tenancy.BaseDomain`, and host-only settings. ```json { "Cratis": { "Arc": { "CorrelationId": { "HttpHeader": "X-Correlation-ID" }, "Tenancy": { "ResolverType": "Header", "HttpHeader": "x-cratis-tenant-id" }, "GeneratedApis": { "RoutePrefix": "api", "SegmentsToSkipForRoute": 0, "IncludeCommandNameInRoute": true, "IncludeQueryNameInRoute": true }, "Query": { "KeepAliveInterval": "00:00:30" } } } } ``` Note `ArcOptions.Hosting` (the listen URL) applies only to **Arc.Core** hosts — in an ASP.NET Core app the URL comes from Kestrel and `launchSettings.json`, not from Arc. For the Arc.Core hosting shape, see [Arc.Core Getting Started](/arc/backend/csharp/core/getting-started/). ### Configuration Properties #### CorrelationId Controls how correlation IDs are handled in HTTP requests. * **HttpHeader** (string, default: `"X-Correlation-ID"`): The HTTP header name to use for correlation ID tracking. #### Tenancy Controls how the active tenant is resolved on each request. * **ResolverType** (`TenantResolverType`, default: `Header`): How to resolve the tenant — `Header`, `Query`, `Claim`, `Subdomain`, `Development`, or `Fixed`. * **HttpHeader** (string, default: `"x-cratis-tenant-id"`): The HTTP header used when `ResolverType` is `Header`, and the fallback header when it is `Subdomain`. * **QueryParameter** (string, default: `"tenantId"`): The query-string parameter used when `ResolverType` is `Query`. * **ClaimType** (string, default: `"tenant_id"`): The claim used when `ResolverType` is `Claim`. * **FixedTenantId** (string, default: `"development"`): The tenant every request resolves to when `ResolverType` is `Fixed` or `Development`. Prefer `Fixed` for a single-tenant production deployment — `Development` resolves identically but is named for an environment it does not check. * **DevelopmentTenantId** (string, default: `"development"`): The same value under its original name — reading or writing either key sets both. Supply only one; if both are present the binder’s property order decides. #### GeneratedApis Controls how automatically generated API endpoints are configured for commands and queries. * **RoutePrefix** (string, default: `"api"`): The base route prefix for all generated API endpoints. * **SegmentsToSkipForRoute** (int, default: `0`): Number of namespace segments to skip when constructing routes from type namespaces. * **IncludeCommandNameInRoute** (bool, default: `true`): Whether to include the command type name as the last segment of the route for command endpoints. * **IncludeQueryNameInRoute** (bool, default: `true`): Whether to include the query name as the last segment of the route for query endpoints. * **EnableQueryHttpMethod** (bool, default: `true`): Whether generated queries also accept HTTP `QUERY` with arguments in a JSON body. GET remains available. See [HTTP QUERY](/arc/backend/csharp/queries/using-the-http-query-method/). #### Query Controls observable (real-time) queries. * **KeepAliveInterval** (`TimeSpan`, default: `00:00:30`): How often a keep-alive frame is sent on an open observable-query connection. #### IdentityDetailsProvider * **IdentityDetailsProvider** (Type, default: `null`): Specifies a custom identity details provider type. If not specified, the system will use type discovery to find one automatically. ## Setup Methods The ASP.NET Core host bootstraps with `WebApplication.CreateBuilder`, registers Arc with `AddCratisArc`, and activates it with `UseCratisArc`. ### Using Configuration File The most common approach is to use the configuration file with the default section: ```csharp var builder = WebApplication.CreateBuilder(args); // Bind the default configuration section (Cratis:Arc) builder.AddCratisArc(); var app = builder.Build(); app.UseCratisArc(); app.Run(); ``` ### Using Custom Configuration Section You can specify a custom configuration section path: ```csharp var builder = WebApplication.CreateBuilder(args); // Bind a custom configuration section builder.AddCratisArc(configSectionPath: "MyApp:CratisConfig"); var app = builder.Build(); app.UseCratisArc(); app.Run(); ``` ### Programmatic Configuration You can configure the options through code with the `configureOptions` callback: ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(options => { // Configure correlation ID options.CorrelationId.HttpHeader = "X-My-Correlation-ID"; // Configure tenancy options.Tenancy.HttpHeader = "X-Custom-Tenant"; // Configure generated APIs options.GeneratedApis.RoutePrefix = "myapi"; options.GeneratedApis.SegmentsToSkipForRoute = 2; options.GeneratedApis.IncludeCommandNameInRoute = false; options.GeneratedApis.IncludeQueryNameInRoute = false; // Set a custom identity details provider options.IdentityDetailsProvider = typeof(MyCustomIdentityDetailsProvider); }); var app = builder.Build(); app.UseCratisArc(); app.Run(); ``` ### Hybrid Configuration `AddCratisArc` binds `appsettings.json` first and then applies the `configureOptions` callback, so the callback acts as a programmatic override on top of file-based settings: ```csharp var builder = WebApplication.CreateBuilder(args); // Values come from Cratis:Arc; the callback overrides specific settings builder.AddCratisArc(options => { options.GeneratedApis.RoutePrefix = "v1/api"; }); var app = builder.Build(); app.UseCratisArc(); app.Run(); ``` ## Turning controllers off `AddCratisArc` registers ASP.NET Core MVC and discovers the controllers in your project assemblies. An application that only uses model-bound commands and queries doesn’t need MVC. Turn it off with `WithoutControllers` on the Arc builder: ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(configureBuilder: arc => arc.WithoutControllers()); var app = builder.Build(); app.UseCratisArc(); app.Run(); ``` Controllers are on unless you call `WithoutControllers` or turn them off with the `CratisArcControllersSupport` property (see below). With controllers off: * Model-bound commands and queries, observable queries included, work as before. `UseCratisArc` maps their endpoints without MVC. * Controller-based commands and queries aren’t available, because no controllers are discovered. * MVC-only features are unavailable. Calling `MapControllers` fails, because MVC isn’t registered, and `[FromRequest]` model binding, `[AspNetResult]` and MVC model validation are unavailable. Arc still validates model-bound commands and queries. * OpenAPI documents describe the model-bound endpoints only. * The application registers the ASP.NET Core services it used to get from MVC. Add `builder.Services.AddCors()` before `UseCors`, and `builder.Services.AddEndpointsApiExplorer()` when you use Swashbuckle. Arc registers the ASP.NET Core authentication and authorization services, so `UseAuthentication`, `UseAuthorization` and policies work without calling `AddAuthorization`. Authentication schemes still come from your own `AddAuthentication(...)`, and protected introspection needs a default scheme, as it does with controllers on. `WithoutControllers` works with `AddCratis` too, through `configureArcBuilder`. The `IHostBuilder` overload of `AddCratisArc` doesn’t support `configureBuilder` (passing one throws), so there it’s only the MSBuild property below that turns controllers off. ### Removing MVC from a trimmed or NativeAOT publish `WithoutControllers` is a runtime choice. `AddCratisArc` still references the code that registers MVC, so a trimmed or NativeAOT publish keeps it, along with the trim warnings MVC raises. To let the trimmer remove it, turn controllers off in the application’s project file instead: ```xml false ``` The `Cratis.Arc` package turns the property into the `Cratis.Arc.Controllers.IsSupported` runtime switch in the application’s `runtimeconfig.json`. With it set to `false`: * Every `AddCratisArc` overload, the `IHostBuilder` one included, registers no MVC and discovers no controllers, exactly as `WithoutControllers` does, and everything listed above applies. You don’t need to call `WithoutControllers` as well. * A trimmed or NativeAOT publish removes the code that registers MVC, so MVC’s trim warnings and most MVC assemblies drop out of the output. Arc still references a few MVC types elsewhere, so `Microsoft.AspNetCore.Mvc.Core` and `Microsoft.AspNetCore.Mvc.Abstractions` stay. This works for `net8.0`, `net9.0` and `net10.0`. * The rest of Arc isn’t trim or NativeAOT compatible yet, so a trimmed publish still reports trim warnings from Arc itself. The switch is written into the `runtimeconfig.json` of the project that sets the property, so it applies to whichever process runs. A test project that hosts the application, for example through `WebApplicationFactory`, runs with its own `runtimeconfig.json` and keeps MVC unless it sets the property too. Set the property in a `Directory.Build.props` shared by the application and its tests, so tests run the way the published application does. Leave the property unset, or set it to `true`, to keep controllers on. The property only has an effect through the `Cratis.Arc` package; a project that references Arc’s source through a `ProjectReference` has to import `build/Cratis.Arc.targets` itself. ### Configuring MVC when controllers are on With controllers on, `AddCratisArc` registers MVC after the `configureBuilder` callback returns. Configure MVC after `AddCratisArc` returns, not inside `configureBuilder`: ```csharp builder.AddCratisArc(); builder.Services.Configure(options => { /* ... */ }); builder.Services.PostConfigure(options => { /* ... */ }); ``` MVC settings made inside the callback run before Arc’s own setup and before MVC’s defaults exist. Arc’s JSON configuration then overrides yours, Arc’s model binder goes ahead of one you insert, and removing a default formatter finds nothing to remove. ## Hosting under a path prefix For an app reached at `/workbench`, configure ASP.NET Core’s `UsePathBase` **before** `UseRouting` and `UseCratisArc`. It removes the prefix from the request path before endpoint matching; leave Arc’s generated API route prefix (normally `/api`) unchanged. ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(); var app = builder.Build(); app.UsePathBase("/workbench"); app.UseRouting(); app.UseCratisArc(); app.Run(); ``` Set `` in the frontend and set the application router’s `basename` to `/workbench` separately. Neither setting changes the server’s routes; see [React provider configuration](/arc/frontend/react/arc/#hosting-under-a-path-prefix). If a reverse proxy already removes the prefix, the server sees unprefixed paths, so configure the frontend and ingress accordingly. ## Environment-Specific Configuration You can use different configurations for different environments using the standard ASP.NET Core configuration pattern: **appsettings.json** (base configuration): ```json { "Cratis": { "Arc": { "GeneratedApis": { "RoutePrefix": "api" } } } } ``` **appsettings.Development.json** (development overrides): ```json { "Cratis": { "Arc": { "CorrelationId": { "HttpHeader": "X-Dev-Correlation-ID" } } } } ``` **appsettings.Production.json** (production overrides): ```json { "Cratis": { "Arc": { "GeneratedApis": { "RoutePrefix": "v1" } } } } ``` ## Route Generation Examples The `GeneratedApis` configuration affects how routes are generated for your commands and queries. Here are some examples: Given a command class `MyApp.Sales.Commands.CreateOrderCommand`: ### Default Configuration ```json { "GeneratedApis": { "RoutePrefix": "api", "SegmentsToSkipForRoute": 0, "IncludeCommandNameInRoute": true, "IncludeQueryNameInRoute": true } } ``` **Generated route**: `/api/my-app/sales/commands/create-order-command` ### Skip Namespace Segments ```json { "GeneratedApis": { "RoutePrefix": "api", "SegmentsToSkipForRoute": 2, "IncludeCommandNameInRoute": true, "IncludeQueryNameInRoute": true } } ``` **Generated route**: `/api/commands/create-order-command` (skips `MyApp` and `Sales`). ### Exclude Type Names ```json { "GeneratedApis": { "RoutePrefix": "api", "SegmentsToSkipForRoute": 2, "IncludeCommandNameInRoute": false, "IncludeQueryNameInRoute": false } } ``` **Generated route**: `/api/commands` for this single command. A single query with location `MyApp.Sales.Queries` would use `/api/queries` with these options. Route conflicts restore the type name When `IncludeCommandNameInRoute` or `IncludeQueryNameInRoute` is set to `false`, Arc detects route conflicts. If multiple commands or queries remain in the same namespace after skipping segments, Arc includes the type name in the route. Within those generated command or query groups: * Single command/query in a namespace: Route remains clean without the type name * Multiple commands/queries in the same namespace: Type names are automatically added to prevent route collisions * Both runtime endpoint mapping and proxy generation apply this logic consistently For example, with the configuration above: * If you have only `MyApp.Sales.Commands.CreateOrder`, the route will be `/api/commands` * If you have both `MyApp.Sales.Commands.CreateOrder` and `MyApp.Sales.Commands.UpdateOrder`, the routes will be `/api/commands/create-order` and `/api/commands/update-order` respectively (type names added automatically to avoid conflict) Note The runtime `GeneratedApis` settings and the build-time proxy-generation settings must agree. If you change route generation here, mirror it in the `CratisProxies*` MSBuild properties so the generated TypeScript clients call the same routes. See [Proxy Generation Configuration](/arc/backend/csharp/proxy-generation/configuration/). ## JSON Serialization Arc-generated endpoints use `ArcOptions.JsonSerializerOptions`. Arc’s MVC post-configuration copies only the property naming policy and appends Arc converters; it does not copy the entire options object. Settings such as `DefaultIgnoreCondition`, `NumberHandling`, and other MVC serializer options require separate MVC configuration if controller output must match. Plain ASP.NET minimal API endpoints receive only Arc’s concept converters by default (single concepts, concept collections and dictionaries keyed by concepts). Concepts in request and response bodies use their underlying primitive JSON values; other types retain ASP.NET’s JSON defaults. Manual serialization must explicitly use the Arc options. ### Default Configuration Arc’s own `JsonSerializerOptions` has the following defaults (not all are copied to MVC): * **Property Naming**: Camel case with acronym-friendly handling (e.g., `XMLParser` becomes `xmlParser`) * **Null Handling**: Null values are ignored when writing JSON * **Enums**: Serialized as integers (not strings) * **Concepts**: Full support for Cratis Concepts (strongly-typed primitives) * **Date/Time**: Support for `DateOnly` and `TimeOnly` types * **Types**: Support for `System.Type` and `System.Uri` serialization * **Derived Types**: Polymorphic serialization support when derived types are discovered ### Accessing JsonSerializerOptions The configured `JsonSerializerOptions` is available through dependency injection: ```csharp using System.Text.Json; public class MessageSerializer(JsonSerializerOptions jsonOptions) { public string Serialize(string message) => JsonSerializer.Serialize(new { Message = message }, jsonOptions); } ``` Or through `ArcOptions`: ```csharp public class MyService { public MyService(IOptions arcOptions) { var jsonOptions = arcOptions.Value.JsonSerializerOptions; } } ``` ### Customizing JSON Serialization You can add custom converters or modify the configuration through the options pattern: ```csharp builder.AddCratisArc(options => { // Add a custom converter options.JsonSerializerOptions.Converters.Add(new MyCustomConverter()); }); ``` For controllers, configure null omission, number handling, and other non-copied settings separately through MVC’s `AddJsonOptions`. Arc’s post-configuration still sets the naming policy, removes the first `JsonStringEnumConverter` if present, and appends Arc converters. `ConfigureHttpJsonOptions` configures ASP.NET minimal API serialization; it does **not** replace Arc’s serializer for generated endpoints. For minimal APIs, ASP.NET Core’s camel-case naming remains unchanged, including when you explicitly choose `JsonNamingPolicy.CamelCase`. Configure `ConfigureHttpJsonOptions` to choose another naming policy, opt out of primitive concept serialization by registering an application converter for that concept type (for example, a `JsonConverter` that writes and reads `{ "value": "..." }`), or set null omission, number handling, and other serializer settings. Application converters keep their order and take precedence over Arc’s appended concept converters. Converter order matters: the first matching converter wins. Enum, date/time, URI, type, geospatial and derived-type converters are **not** added to plain minimal APIs; configure those explicitly if needed. Test the actual request/response contract; see [OpenAPI enum schemas](/arc/backend/csharp/open-api/enums/). ### Adding Source-Generated Metadata Arc resolves the metadata for its own wire types - `CommandResult`, `QueryResult`, change sets, observable query messages and the identity responses - from a source-generated `JsonSerializerContext` it ships. Everything else, such as your read models, command responses and query arguments, resolves through reflection. In a trimmed or NativeAOT application reflection-based serialization is disabled, so Arc needs metadata for those types from you. Declare a `JsonSerializerContext` for your types and add it with `AddJsonTypeInfoResolver`: ```csharp using System.Text.Json.Serialization; using Cratis.Arc.Commands; [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] [JsonSerializable(typeof(Order))] [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(PlaceOrder))] [JsonSerializable(typeof(CommandResult))] public partial class AppJsonSerializerContext : JsonSerializerContext; ``` ```csharp builder.AddCratisArc(options => options.AddJsonTypeInfoResolver(AppJsonSerializerContext.Default)); ``` `Order` and `PlaceOrder` stand in for your own read model and command, and `OrderId` for the response `PlaceOrder` returns. Arc’s `JsonSerializerOptions` look up a type’s metadata in this order: 1. The resolvers you add with `AddJsonTypeInfoResolver`. Your contracts win, including any you customize for Arc’s own types. They are consulted first whether you add them before or after you wrap, replace or clear `TypeInfoResolver`; added while Arc’s resolver is still in the chain, they are consulted in the order you add them. 2. Resolvers you append to `JsonSerializerOptions.TypeInfoResolverChain` with `Add`, in the order you append them. 3. Arc’s source-generated metadata for its own wire types. 4. The reflection-based resolver, only when reflection-based serialization is enabled (`JsonSerializer.IsReflectionEnabledByDefault`). This is the resolver `System.Text.Json` falls back to for options without one, so an application that adds nothing serializes exactly as before. The serializer options still decide naming, null handling, number handling and converters, so the JSON is the same whichever resolver supplies the metadata. Use `JsonSourceGenerationMode.Metadata`, as Arc does. Arc’s options carry custom converters, so `System.Text.Json` cannot use a generated serialization fast path with them; generating one only adds code. Keep these in mind: * Register `CommandResult` for every response type your commands return, such as `CommandResult` above. Arc writes a command’s response wrapped in `CommandResult`, where `TResponse` is the response’s runtime type - a generic type its own metadata cannot cover, so registering `TResponse` alone is not enough. Without it, a command that returns a response fails with a `NotSupportedException` when reflection is disabled. * Register the runtime types of query data, not only the declared ones. `QueryResult.Data` and change-set items are written by their runtime type, so if a query returns a `List`, register `List`. * Add resolvers while configuring Arc. `JsonSerializerOptions` become read-only once they have been used. * Arc keeps a single resolver of its own in `TypeInfoResolverChain`, after the resolvers added with `AddJsonTypeInfoResolver` and before anything you append. That resolver consults the resolvers appended after it first, so a resolver you append - for instance a `DefaultJsonTypeInfoResolver` with `Modifiers` that rename or ignore members - still decides the contract of every type it knows, Arc’s own included, as it did when it was the only resolver in the chain. Types it does not know fall through to Arc’s metadata and, when enabled, reflection. The same holds for a copy of Arc’s options, `new JsonSerializerOptions(options)`: a resolver appended to the copy wins for the copy and leaves Arc’s options alone. * Assigning `JsonSerializerOptions.TypeInfoResolver` replaces the whole chain, Arc’s resolver included. The resolver you assign then serializes every type on its own, so with reflection disabled it has to cover Arc’s wire types too. Prefer `AddJsonTypeInfoResolver`. * To add modifiers to every contract, wrap the resolver the options carry: `options.TypeInfoResolver = options.TypeInfoResolver!.WithAddedModifier(...)`. Arc’s resolver stays inside the wrapper, so the modifiers apply to what it resolves too. On .NET 8 and .NET 9, wrap `TypeInfoResolver` before appending anything to `TypeInfoResolverChain`, or build the combination explicitly with `JsonTypeInfoResolver.Combine(...)`: wrapping after appending crashes with a stack overflow on those versions. That is `System.Text.Json` behavior, not Arc’s. * Arc’s options always carry a resolver. `TypeInfoResolver ??= x`, or `if (TypeInfoResolver is null) { ... }`, therefore no longer assigns anything - use `AddJsonTypeInfoResolver`, `TypeInfoResolverChain.Add` or an explicit assignment instead. And `TypeInfoResolver?.WithAddedModifier(...)` now takes effect. * Assigning `options.TypeInfoResolver = JsonTypeInfoResolver.Combine(options.TypeInfoResolver, other)` flattens into Arc’s resolver followed by `other`, so `other` wins for the types it knows, like any appended resolver. Only appending a combination that contains Arc’s resolver, `options.TypeInfoResolverChain.Add(JsonTypeInfoResolver.Combine(options.TypeInfoResolver, other))`, resolves in the order you compose it: Arc’s resolver, and with it Arc’s metadata and reflection, before `other`. * `AddJsonTypeInfoResolver` resolvers are consulted first in all these cases: after you wrap `TypeInfoResolver` they go ahead of the wrapper, and after you set it to `null` Arc’s resolver is composed anew behind them, so Arc’s metadata and the reflection fallback are kept. Once you have wrapped the resolver, a resolver you add later is consulted before the ones you added earlier. * The resolvers apply to Arc’s own endpoints and serialization. MVC controllers and plain minimal APIs keep their own serializer options. When you create options with `ConfigureArcDefaults()` outside `ArcOptions`, a resolver the options already carry stays first, followed by Arc’s resolver: `new JsonSerializerOptions { TypeInfoResolver = AppJsonSerializerContext.Default }.ConfigureArcDefaults()`. ## Best Practices 1. **Use Configuration Files**: For most scenarios, use `appsettings.json` configuration as it allows easy environment-specific overrides without code changes. 2. **Environment-Specific Settings**: Use `appsettings.{Environment}.json` files for environment-specific configurations. 3. **Programmatic Configuration**: Use programmatic configuration when you need to: * Set configuration based on runtime conditions * Use custom identity providers * Override specific settings that can’t be easily expressed in JSON 4. **Route Planning**: Consider your API route structure carefully when configuring `GeneratedApis` options, especially in public-facing APIs where route stability is important. 5. **Header Standardization**: Use standard HTTP header names for correlation IDs and tenant IDs that align with your organization’s conventions and any API gateways or load balancers in use. 6. **JSON Consistency**: Use the injected `JsonSerializerOptions` for manual serialization that must match generated Arc endpoints. Configure and test MVC’s remaining options separately when controller output must match too. # Combine request values with FromRequest > Bind JSON and URL values into one MVC request model, then validate them together. Use `[FromRequest]` when an MVC action needs a single input model assembled from JSON and URL values. Arc handles the merging, so the action and its validator can work with one object instead of stitching parameters together. This is an ASP.NET Core integration feature, not the binding contract for model-bound Arc commands or queries. ## Overview Arc binds the JSON body first, then uses MVC’s other request sources to fill properties that still hold their type’s default value. Non-default body values win. See [default values and fallback](#default-values-and-fallback) when zero, false, or property initializers matter. ## Usage Examples ### Basic Usage In an existing ASP.NET Core Arc application, declare the request model and use it in your controller action: ```csharp using Cratis.Arc.ModelBinding; using Microsoft.AspNetCore.Mvc; public class UserUpdateRequest { public string Name { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; [FromRoute] public int UserId { get; set; } [FromQuery] public bool NotifyUser { get; set; } } [HttpPut("users/{userId}")] public IActionResult UpdateUser([FromRequest] UserUpdateRequest request) { // Apply the update through your application service after validation. return Ok(); } ``` **Request Example:** ```http PUT /users/123?notifyUser=true Content-Type: application/json { "name": "John Doe", "email": "john@example.com" } ``` The action receives `UserId = 123`, `NotifyUser = true`, and the name and email from JSON. The example shows binding; `Ok()` alone does not persist an update. Note A non-default body value can also override a merged route ID. When the URL must identify the resource being changed, bind that ID separately with `[FromRoute]` and use it in the authorization and update—not the merged property. ### Fallback Scenarios ```csharp public class SearchRequest { public string Query { get; set; } = string.Empty; public int Page { get; set; } = 1; public int PageSize { get; set; } = 10; [FromRoute] public string Category { get; set; } = string.Empty; } [HttpPost("search/{category}")] public IActionResult Search([FromRequest] SearchRequest request) { return Ok(); } ``` **Request with partial JSON:** ```http POST /search/electronics?page=2&pageSize=20 Content-Type: application/json { "query": "laptop" } ``` Result: * `Query` = “laptop” (from JSON body) * `Page` = 1 (initializer survives; it is not CLR default `0`) * `PageSize` = 10 (initializer survives) * `Category` = `""` (initializer survives; it is not CLR default `null`) **Request with complete JSON:** ```http POST /search/electronics?page=2&pageSize=20 Content-Type: application/json { "query": "laptop", "page": 5, "pageSize": 50 } ``` Result: * `Query` = “laptop” (from JSON body) * `Page` = 5 (from JSON body, overrides query parameter) * `PageSize` = 50 (from JSON body, overrides query parameter) * `Category` = `""` (initializer survives) To make the category eligible for route fallback, declare it as `[FromRoute] public string? Category { get; set; }` and validate it after binding. To allow paging fallback, do not initialize the numeric properties to nonzero values; apply business defaults after merging. `[FromRequest]` does not track field presence, so it cannot distinguish an omitted integer from an explicitly supplied zero. []() ## Default values and fallback Fallback compares a property’s value with its type’s default; it does not track whether the JSON field was present. | Body-bound value | Eligible for fallback? | | --- | --- | | `0` for an integer | Yes | | `false` for a Boolean | Yes | | `null` for a reference | Yes | | An empty string | No | | A nonzero property initializer, such as `Page = 1` | No | An explicit JSON zero or false can therefore be replaced by a route/query value. Apply application defaults after binding when you want missing values to remain eligible for fallback. ## Benefits * **Cross-parameter validation:** a rule can check related values together—for example, require an email address when the request asks to notify the user. * **One validation model:** the validator receives the combined input, regardless of which request source supplied each value. * **Less controller plumbing:** the action receives one model instead of assembling it from separate parameters. * **Focused validator specs:** test rules directly with request objects; use separate HTTP tests to verify binding. Adding merging can change an existing endpoint’s contract. Keep the precedence deliberate and test the request combinations your clients use. ### Validation Scenarios With one request model, a validator can check the name, email, and notification preference together. **Traditional approach (multiple parameters):** ```csharp [HttpPut("users/{userId}")] public IActionResult UpdateUser( [FromRoute] int userId, [FromQuery] bool notifyUser, [FromBody] UserUpdateData data) { // Combine the inputs before applying cross-property rules. return Ok(); } ``` **With \[FromRequest]:** ```csharp using Cratis.Arc.ModelBinding; using FluentValidation; using Microsoft.AspNetCore.Mvc; public class UserUpdateRequest { [FromRoute] public int UserId { get; set; } [FromQuery] public bool NotifyUser { get; set; } public string Name { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; } public class UserUpdateRequestValidator : AbstractValidator { public UserUpdateRequestValidator() { RuleFor(x => x.UserId).GreaterThan(0); RuleFor(x => x.Name).NotEmpty().MaximumLength(100); RuleFor(x => x.Email).EmailAddress(); // Cross-property validation is now possible RuleFor(x => x.NotifyUser) .Equal(false) .When(x => string.IsNullOrEmpty(x.Email)) .WithMessage("Cannot notify user without email address"); } } [HttpPut("users/{userId}")] public IActionResult UpdateUser([FromRequest] UserUpdateRequest request) { // Validate the merged request before applying the update. return Ok(); } ``` Register and invoke the validator through your MVC validation setup. `[FromRequest]` performs binding; declaring an `AbstractValidator` alone does not establish when it runs. Test the validator directly with merged request objects, and use an HTTP test to check binding. ## Integration with Other Features ### Swagger/OpenAPI Arc’s `FromRequestOperationFilter` describes `[FromRequest]` parameters as body schemas. Check the generated operation’s route and query parameters too; see [Swagger integration](/arc/backend/csharp/asp-net-core/swagger/) for its configuration and limits. ### Proxy Generation Model binding and client generation have separate requirements. Check the [proxy generator’s supported endpoint shapes](/arc/backend/csharp/proxy-generation/) when choosing the client for a mixed-source MVC action. ## Best Practices * Use `[FromRequest]` when combining request sources reduces manual binding work. * Keep fallback-eligible properties unset until merging finishes. * Document the permitted sources and precedence for your clients. * Validate the merged input and authorize the resource before writing. ## Limitations Merging compares values, not JSON field presence. For partial updates that must distinguish “omitted” from an explicit zero, false, or null, use a request contract that represents that distinction. Test nested models and conflicting input sources through HTTP; a direct validator test does not exercise binding. # Invariant Culture > Make an ASP.NET Core Arc host use invariant culture with UseInvariantCulture() so parsing and formatting do not depend on machine settings. Arc provides a `.UseInvariantCulture()` extension method that configures your application to use invariant culture throughout, ensuring consistent and predictable behavior regardless of the host machine’s regional settings. ## Why Invariant Culture? Modern distributed applications run across different machines, containers, and cloud regions—each potentially configured with different regional settings. Without explicit culture configuration, operations like number parsing, date formatting, or string comparisons may produce inconsistent results depending on where the code runs. Invariant culture supplies predictable defaults for culture-sensitive code; explicit per-call or per-thread culture choices can still override them: * **DateTime formatting and parsing** uses invariant conventions by default; it does not choose a time zone * **Number formatting and parsing** produces consistent results (e.g., decimal separator is always `.`) * **String comparisons and sorting** are culture-independent * **Serialization and deserialization** still follows each serializer’s contract and explicit options ## Configuration In an ASP.NET Core web project with `Cratis.Arc`, import `Cratis.Arc`. Call `UseInvariantCulture()` on the `WebApplicationBuilder` before building the application, and on the resulting `WebApplication` to activate the request localization middleware: ```csharp var builder = WebApplication.CreateBuilder(args); builder.UseInvariantCulture(); builder.AddCratisArc(); var app = builder.Build(); app.UseInvariantCulture(); app.UseCratisArc(); await app.RunAsync(); ``` > **Important**: Call `app.UseInvariantCulture()` before any middleware that processes request data to ensure invariant culture is applied to all incoming requests. ## What Gets Configured Calling `UseInvariantCulture()` on the builder: * Sets `CultureInfo.DefaultThreadCurrentCulture` to `CultureInfo.InvariantCulture` * Sets `CultureInfo.DefaultThreadCurrentUICulture` to `CultureInfo.InvariantCulture` * Configures `RequestLocalizationOptions` to use only `InvariantCulture` * Removes all request culture providers, preventing clients from overriding the culture via `Accept-Language` headers or query strings Calling `UseInvariantCulture()` on the application: * Registers the ASP.NET Core request localization middleware with the invariant culture settings ## Practical Example Consider an API that accepts a price value. Without invariant culture, `"1,5"` might parse as `1.5` on a European machine but fail on an English-locale machine. Invariant culture stabilizes culture-sensitive parsing defaults. JSON numeric values already use JSON’s culture-independent syntax; request localization does not define that wire format. This complete ASP.NET `Program.cs` example receives a numeric JSON `price`: ```csharp using Cratis.Arc; var builder = WebApplication.CreateBuilder(args); builder.UseInvariantCulture(); builder.AddCratisArc(); var app = builder.Build(); app.UseInvariantCulture(); app.UseCratisArc(); app.MapPost("/products", (ProductRequest request) => { // JSON binding uses the serializer's numeric contract return Results.Ok(new { Price = request.Price }); }); await app.RunAsync(); record ProductRequest(decimal Price, DateOnly ExpiryDate); ``` ## When to Use Invariant culture is recommended for: * **APIs and services** that exchange data between systems * **Applications deployed in multiple regions** or cloud environments * **Data processing pipelines** where consistency is critical * **Any scenario** where culture-sensitive behavior could lead to bugs or data inconsistencies It is generally safe to apply invariant culture in back-end services, as the locale-specific formatting should be handled on the client side (e.g., a frontend application) rather than in the API itself. # Microsoft Identity > Build application identity details from Microsoft identity platform headers supplied by a trusted ingress. Cratis’ Arc provides a way to easily work with providing an object that represents properties the application finds important for describing the logged in user. The purpose of this is to provide details about the logged in user on the ingress level of an application and letting it provide the details on the request going in. Having it on the ingress level lets you expose the details to all microservices behind the ingress. The values provided by the provider are values that are typically application specific and goes beyond what is already found in the token representing the user. This is optimized for working with Microsoft Azure well known HTTP headers passed on by the different app services, such as Azure ContainerApps or WebApps. Internally, it is based on the following HTTP headers to be present. | Header | Description | | --- | --- | | x-ms-client-principal | The unsigned principal payload holding the details, base64 encoded [Microsoft Client Principal Data definition](https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=csharp#client-principal-data) | | x-ms-client-principal-id | The unique identifier from the identity provider for the identity | | x-ms-client-principal-name | The name of the identity, typically resolved from claims within the token | > Important note: Since local development is not configured with the identity provider, but you still need a way to test that both the backend and the frontend deals with the identity in the correct way. This can be achieved by creating the correct token and injecting it as request headers using a browser extension. Read more about [generating principal tokens for local development](/arc/backend/csharp/development/generating-principal/). The token in the `x-ms-client-principal` should be a base64 encoded [Microsoft Client Principal Data definition](https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=csharp#client-principal-data). Caution Use these headers only behind ingress that authenticates callers, strips caller-supplied identity headers, writes trusted replacements, and prevents direct backend access. Base64 is not a signature. Generated principals are local test fixtures, not credentials to accept on an internet-facing service. The identity Arc returns from enrichment is likewise not an authentication ticket. ## Authentication / Authorization To get the Microsoft Client Principal supported in your backend, the Arc offers an `AuthenticationHandler` that supports the HTTP headers and does the right thing to put ASP.NET Core and every `HttpContext` in the right state. You can add this by calling the `AddMicrosoftIdentityPlatformIdentityAuthentication()` method on your services. This is a registration fragment for an ASP.NET Core application using `Cratis.Arc` (not `ArcApplication`): ```csharp using Cratis.Arc; using Cratis.Arc.Identity; var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(options => options.TrustForwardedIdentityHeaders = true); builder.Services.AddMicrosoftIdentityPlatformIdentityAuthentication(); builder.Services.AddAuthorization(); ``` The above code will then also call the `.AddAuthentication()` with the default scheme name (**MicrosoftIdentityPlatform**) and register the appropriate `AuthenticationHandler` for that scheme. ### Trusting the forwarded headers Registering the scheme is not enough on its own. Because the headers are not signed, the handler ignores them and returns no result, so requests stay anonymous unless another scheme authenticates them, until the host opts in with `TrustForwardedIdentityHeaders`. Set it in code, as above, or in configuration: ```json { "Cratis": { "Arc": { "TrustForwardedIdentityHeaders": true } } } ``` Opt in only when every request reaches the application through an ingress that strips caller-supplied identity headers and sets its own, such as Azure App Service or Container Apps authentication (EasyAuth) or Cratis AuthProxy. A host that calls `AddMicrosoftIdentityPlatformIdentityAuthentication()` (including through `AddCratis()`) while the headers are not trusted logs a warning that names this setting on every startup. The built-in header handlers also warn on the first request that carries untrusted headers. The setting applies to every endpoint, including protected [introspection catalogs](/arc/backend/csharp/introspection/). You can override the scheme name on the extension method by passing your own string as an argument. For it to be appropriately setup, you’ll need to enable the default authentication and authorization on your app, like below: ```csharp var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.UseCratisArc(); app.Run(); ``` This establishes the host authentication mechanism; apply [authorization requirements](/arc/backend/csharp/asp-net-core/authorization/) to private operations. Identity details do not automatically protect every endpoint. ## Knowing which identity provider signed the caller in The `x-ms-client-principal` payload carries an `identityProvider` field describing the provider the ingress authenticated the caller with. Without it, everything downstream sees is a set of claims that look the same whether they came from Entra ID or GitHub — so the application has no way to tell one federation apart from another. Arc keeps that value on the reconstructed principal as a claim, so both normal request authorization and the `/.cratis/me` identity resolution can read it: ```csharp using Cratis.Arc.Identity; using Microsoft.AspNetCore.Http; public class IdentityProviderReader(IHttpContextAccessor httpContextAccessor) { public string? Current => httpContextAccessor.HttpContext?.User.FindFirst(MicrosoftIdentityPlatformClaims.IdentityProvider)?.Value; } ``` | Aspect | Detail | | --- | --- | | Claim type | `urn:cratis:arc:identity:provider` — use the `MicrosoftIdentityPlatformClaims.IdentityProvider` constant | | Value | The exact `identityProvider` value the ingress forwarded, for example `aad` or `github` | | When absent | The forwarded principal carried no `identityProvider` field, or the field held only blank characters | The claim type is **reserved for Arc**. The `x-ms-client-principal` header is base64, not a signature, so any caller that can reach the application can put whatever it likes in the serialized payload — including a claim of this very type. Arc therefore removes every claim of the reserved type from the deserialized payload, ignoring casing, *before* writing its own value. Caution **What the strip guarantees is single provenance, not authenticity.** When a nonblank provider value is supplied, the claim carries exactly one value, and that value always comes from one place — the `identityProvider` field of the forwarded principal — so a claim of the reserved type passed through by the ingress or by the identity provider can never displace it. It does **not** make the value trustworthy. The same unsigned header carries that field too, and Arc does not check who sent the header, so a caller that can reach the application authors the whole document; the strip only decides which of its fields wins. **Trust this claim exactly as far as you trust the `x-ms-client-principal` header itself — that is, only insofar as your ingress is the only thing that can set it.** Terminate the header at the ingress and reject or overwrite an inbound one; nothing inside Arc can do that for you. Read the claim with `FindFirst` or `FindAll`, as in the example above, and **never normalize the claim type yourself**. Those lookups compare the claim type the same way the strip does, so what they return is exactly what Arc wrote. Enumerating `User.Claims` and folding the type with `ToUpperInvariant()` or `Trim()` widens the match beyond what was removed — a forged type differing only by Unicode case folding (`urn:cratiſ:arc:identity:provider`, U+017F) or by trailing whitespace then matches, and because forwarded claims are added before Arc’s own, a `FirstOrDefault()` over that widened set returns the forgery. Note The value is the exact `identityProvider` field the ingress forwarded, verbatim and untrimmed — Arc neither interprets nor normalizes it. What it *means* is therefore the ingress’s choice, not Arc’s. Cratis AuthProxy forwards the canonical provider key, the same value it publishes as its own `urn:cratis:identity:provider-key` claim, so in a canonical AuthProxy deployment the two carry identical values. Another ingress may forward an authentication scheme name or a provider display name that changes when the provider is renamed. **Arc guarantees neither**, so use this claim for telling federations apart, for diagnostics, and for provider-aware behavior. If you need a durable provider key, read the claim the ingress publishes for that purpose — `urn:cratis:identity:provider-key` in an AuthProxy deployment — rather than this one. Arc copies such ingress-authored claims through untouched. ## Identity Details For information about providing additional identity details for logged-in users, including authorization checks and custom identity information, see the [Identity documentation](/arc/backend/csharp/identity/). Use Arc’s identity-details provider to add application information to the principal supplied by Microsoft Identity. Those details help the UI; authorization still checks the authenticated principal. # Swagger > Describe Arc commands, queries, and concepts in Swashbuckle Swagger documents with the Cratis.Arc.Swagger filters. Cratis Arc provides Swagger/OpenAPI filters through the optional `Cratis.Arc.Swagger` package for ASP.NET Core hosts. These describe Arc conventions, but do not guarantee that every generated schema matches the runtime contract. Review the [current limitations](#current-limitations) before generating clients. ## Overview The Swagger extension adds filters for: * **Concepts** - Maps concept types to their underlying primitive types * **Commands** - Adds result-wrapper and error response schemas * **Queries** - Adds result-wrapper schemas and paging/sorting parameters where applicable * **FromRequest attributes** - Describes request-body binding * **Model-bound endpoints** - Attempts to enrich generated command and query operations ## Setup Add the `Cratis.Arc.Swagger` package, which brings Swashbuckle with it, then register the document generator with Arc’s filters and expose the document and UI in `Program.cs`: ```csharp using Cratis.Arc.Swagger; var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => options.AddConcepts()); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseCratisArc(); await app.RunAsync(); ``` `AddConcepts()` registers the schema and operation filters; it does not serve anything by itself. `UseSwagger()` serves `/swagger/v1/swagger.json`, which lists the Arc command, validate, and query routes alongside Arc’s `/.cratis/*` endpoints, and `UseSwaggerUI()` serves the browser UI at `/swagger`. The example serves both only in Development; decide deliberately whether a production host should publish its API description. ## Features ### Concept Schema Filter Automatically maps concept types (types inheriting from `ConceptAs`) to their underlying primitive types in the Swagger schema. This ensures that concepts appear as their actual data types (string, int, Guid, etc.) rather than complex objects in the API documentation. **Example:** ```csharp using Cratis.Concepts; public record UserId(Guid Value) : ConceptAs(Value); // In Swagger, UserId parameters will appear as string (UUID format) // instead of a complex object with a Value property ``` ### Command Result Operation Filter Enhances command endpoints by: * Adding `CommandResult` or `CommandResult` response schemas based on inferred return types * Including standard HTTP status codes (200, 400, 403, 500) with appropriate error schemas * Handling void/Task return types correctly * Supporting concept return types ### Query Result Operation Filter Enhances query endpoints by: * Adding `QueryResult` response schemas * Including standard HTTP status codes with error handling * Adding pagination and sorting parameters where the filter detects paging support * Supporting concept return types ### FromRequest Operation Filter Properly handles the `[FromRequest]` attribute by: * Removing the parameter from the query string/path parameters * Adding it as a request body with the correct JSON schema * Supporting complex model binding scenarios ### Model-Bound Operation Filters Attempts to match generated command and query operations to registered handlers and performers, then adds request and result schemas. Matching and type inference have the limitations below. ### Pagination and Sorting Parameters For matched model-bound query performers with `SupportsPaging = true` (`IQueryable` results), the filter adds the following parameters: | Parameter | Type | Description | | --- | --- | --- | | `sortby` | string | Field name to sort by | | `sortDirection` | string | Sort direction (`asc` or `desc`) | | `pageSize` | integer | Number of items per page | | `page` | integer | Page number (0-based) | ## Enum Schema Filter Replaces enum values with their names, without changing the schema type. This can leave string members in an integer schema and does not change Arc’s default numeric wire format. See [enum limitations](/arc/backend/csharp/open-api/enums/). ## Response Schemas The filters add the following response descriptions when they match an operation. These are generated descriptions, not proof of the runtime payload or every possible status: ### Success Responses (200) * Commands: `CommandResult` or `CommandResult` * Queries: `QueryResult`; the model-bound filter does not supply a concrete typed `data` contract ### Error Responses * **400 Bad Request**: Validation errors or malformed requests * **403 Forbidden**: Authorization failures * **500 Internal Server Error**: Unexpected server errors The model-bound filters reuse the result schema for the listed error responses; compare it with actual error responses from your host. ## Current limitations The Swagger implementations share the limitations of the [Microsoft OpenAPI model-bound transformers](/arc/backend/csharp/open-api/model-bound/) and [enum transformer](/arc/backend/csharp/open-api/enums/): * **Command adapter inference:** The model-bound command filter inspects the registered adapter’s `Handle`, not the command record’s return contract. The standard adapter returns `ValueTask`; this does not recover your concrete DTO, `Guid`, or typed-result response schema. Ordinary `Guid` command responses remain supported at runtime — the gap is schema inference. * **Query matching and requiredness:** The query filter compares the operation-name suffix with `performer.Name`, while the GET endpoint mapper names operations with `performer.FullyQualifiedName`. It can therefore miss normal model-bound queries entirely. If it matches, it marks all query parameters `Required = false`, including arguments that runtime binding requires. * **Query data schema:** The model-bound filter generates plain `QueryResult`, not a wrapper specialized to the query’s concrete `data` type. Do not assume that this schema is enough to generate a typed query client. * **Enum type mismatch:** The enum filter inserts string names without changing the schema type. An integer schema with string enum members is inconsistent with itself and with Arc’s default numeric payloads. Switching between `Cratis.Arc.OpenApi` and `Cratis.Arc.Swagger` does not resolve these shared gaps. Inspect generated documents and compare representative command/query payloads, required arguments, enums, and errors with actual HTTP responses before client generation. ## Integration with Arc Features Use these guides to establish the runtime contract independently of the generated document: * **[FromRequest](/arc/backend/csharp/asp-net-core/from-request/)**: Request binding * **[Commands](/arc/backend/csharp/commands/)**: Command execution and responses * **[Queries](/arc/backend/csharp/queries/)**: Query results and paging * **[Validation](/arc/backend/csharp/asp-net-core/validation/)**: Validation failure information * **[Without Wrappers](/arc/backend/csharp/asp-net-core/without-wrappers/)**: Response unwrapping — verify the schema against the unwrapped payload too # Validation > Validate input in ASP.NET Core with Arc's command, query, and concept validators, discovery, and concept-aware rules. The concept of validation is to make sure all your inputs are in a valid form before hitting your logic. Validation is different from business rules in the sense that it is focused on user input and sanitizing the values coming as input. While business rules tend to be stateful based on state of your system to be able to validate for correctness. Examples below are **illustrative model/validator fragments** for an existing ASP.NET Core Arc application, not complete programs. Supply the application-owned account/user types and import `FluentValidation`, `Cratis.Concepts`, and the indicated Arc validator namespace. Controller request models need not be model-bound `[Command]` types. ## Default behavior The default behavior when using the `CommandActionFilter` is to stop the execution of a command coming in if it is invalid. It will automatically look at the `ModelState` of the currently executing controller action and stop execution if things are invalid. The result will then be put on the `CommandResult` as validation errors for any consumers to react to. ## Value based validators Value-based validation uses ASP.NET Core’s [validation attributes](https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation#custom-attributes). For example, use `[Required]`: ```csharp public record OpenDebitAccount( [Required] AccountId AccountId, AccountDetails Details); ``` Attributes are compile time and just represent metadata, the downside of this is that if you’re having context dependent rules or need to inject localized strings or similar you won’t be able to get to it. ## Fluent Validation Another alternative approach to validation is to use [FluentValidation](https://docs.fluentvalidation.net). Cratis comes with this all setup and automatically hooks up different types of validators for specific purposes. ### Base Validator The formalized validator types (`CommandValidator`, `QueryValidator` and `ConceptValidator`) all derive from a type called `BaseValidator`. This base type provides methods for defining validation rules for known `ConceptAs<>` primitives and will unwrap the inner `Value` property automatically, providing you a clean way of validating the concepts inner primitive type without considering the `Value` property. `RuleFor(c => c.Name)` on a concept property returns an `IConceptRuleBuilder` whose rules run against the unwrapped primitive. To state a rule about the concept itself, put it in a `ConceptValidator`: it runs wherever that concept appears. When one command must not apply that cross-cutting concept rule, call `IgnoreConceptRules()` directly after `RuleFor(...)`, for example `RuleFor(c => c.Name).IgnoreConceptRules().NotEmpty()`. ### Discoverable validators Validators can be automatically discovered. This done through the discovery of anything that implements the marker interface `IDiscoverableValidator<>`. The formalized types for Commands, Queries and Concepts all implement this. There is also a base type that can be used, which inherits from `BaseValidator` to give you the additional functionality it provides. All you need to do is inherit from `DiscoverableValidator<>`. ### Command Validator To write command validators, all you need to do is implement the `CommandValidator<>` class and create rules for your properties. Lets say you have a command as follows: ```csharp public record OpenDebitAccount( AccountId AccountId, AccountDetails Details); ``` A validator for this could then be as follows: ```csharp using Cratis.Arc.Commands; public class OpenDebitAccountValidator : CommandValidator { public OpenDebitAccountValidator() { RuleFor(_ => _.Details.Name).NotEmpty().WithMessage("Name is required"); RuleFor(_ => _.Details.Owner).NotNull().WithMessage("Owner is required"); RuleFor(_ => _.Details.IncludeCard).NotNull().WithMessage("Include card should be specified"); } } ``` ### Concept Validator When one is using [domain concepts](/arc/glossary/), you have the opportunity to create a validator for the concept that will automatically be used as part of the ASP.NET Core validation pipeline. The benefit of this approach is that you can reuse validation rules and they will automatically implicitly be hooked up, leading to not have to remember to explicitly add every rule for reused concepts. The tradeoff of this is obviously that your rules are scattered around. Say you have a concept as follows: ```csharp public record AccountName(string Value) : ConceptAs(Value); ``` By inheriting the `ConceptValidator<>` type you can create rules for the concept: ```csharp using Cratis.Arc.Validation; public class AccountNameValidator : ConceptValidator { public AccountNameValidator() { RuleFor(_ => _).Length(0, 16).WithMessage("Account name has to be less than 16 characters"); } } ``` ### Conditional validation FluentValidation supports the concept of [conditions](https://docs.fluentvalidation.net/en/latest/conditions.html) for validation, in the API you can see `When()` for different levels. In Cratis Arc you’ll find that every validator that is a discoverable validator (Concept, Command…) have methods on the base type that offers convenience conditions for whether or not the request is a **command** or a **query** called `WhenCommand()` or `WhenQuery()`. With this you can build rule-sets that are specific the scenario of a command or a query given a specific object you’re validating. The use case for this is most relevant for **concepts** where the concept type is used by both commands and queries. Following is an example for an RBAC system where you have system users that can’t be modified and you want to cross cuttingly apply the validation for all command operations that work on the `UserId` concept but not for any queries that has the same type as an argument. ```csharp using Cratis.Arc.Validation; public class UserIdValidator : ConceptValidator { public UserIdValidator() { RuleFor(userId => userId) .NotNull() .UserMustExist().WithMessage("User does not exist."); RuleFor(userId => userId) .UserMustNotBeSystem().WithMessage("Operation is not allowed on a system user.") .WhenCommand(); } } ``` > Note: The rules `UserMustExist()`and `UserMustNotBeSystem()` are an example of extension methods that you could implement. The implementation is irrelevant for the example. The code sets up a rule that is general without a condition, then it applies a rule for when it is a **command**. With the `WhenCommand()` extension you can also specify whether or not you want it to apply for the entire validation chain or just the current. All validators are default, you can then use `ApplyConditionTo.CurrentValidator` for only the current validator. With the `ApplyConditionTo.CurrentValidator` you can merge into one rule-set: ```csharp using Cratis.Arc.Validation; public class UserIdValidator : ConceptValidator { public UserIdValidator() { RuleFor(userId => userId) .NotNull() .UserMustExist().WithMessage("User does not exist.") .UserMustNotBeSystem().WithMessage("Operation is not allowed on a system user.") .WhenCommand(FluentValidation.ApplyConditionTo.CurrentValidator); } } ``` If you have multiple rules that should only apply when it is a **command** or **query** you can use the action method: ```csharp using Cratis.Arc.Validation; public class UserIdValidator : ConceptValidator { public UserIdValidator() { RuleFor(userId => userId) .NotNull() .UserMustExist().WithMessage("User does not exist."); WhenCommand(() => { RuleFor(userId => userId) .UserMustNotBeSystem().WithMessage("Operation is not allowed on a system user.") .UserMustNotBeAdministrator().WithMessage("Operation is not allowed on an administrator user."); }); } } ``` # Without wrappers > Return native ASP.NET controller results instead of Arc command/query envelopes. Arc’s participating controller actions normally return `CommandResult` or `QueryResult` envelopes. For an endpoint whose external contract must remain a native ASP.NET result, use `[AspNetResult]` on the controller or action. ## Opt out on an action This complete controller fragment assumes an ASP.NET Core Arc host with MVC registration and controller mapping: ```csharp using Cratis.Arc; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/status")] public class StatusController : ControllerBase { [HttpGet] [AspNetResult] public IActionResult Get() => Ok(new { State = "Running" }); } ``` The action’s result remains native ASP.NET output rather than an Arc envelope. Move `[AspNetResult]` above `StatusController` to apply it to every action on that controller. ## Validation still applies Opting out of response wrapping does not disable Arc’s model-state validation checks. Invalid model state can still prevent action execution. Likewise, do not treat the attribute as a way to bypass authentication or endpoint authorization. Test error responses as well as successful payloads when defining an external API contract. ## Proxy generator The proxy generator skips a controller action that carries `[AspNetResult]` itself; no Arc TypeScript proxy is generated for it. It checks the action only, not the controller: with `[AspNetResult]` on the controller class, its `[HttpGet]` and `[HttpPost]` actions still get proxies that expect Arc’s envelope and cannot read the native response. Put the attribute on each action when no proxy should exist, and supply a client appropriate to your unwrapped contract instead. ## See also * [Controller commands](/arc/backend/csharp/commands/controller-based/) * [Controller queries](/arc/backend/csharp/queries/controller-based/) * [OpenAPI](/arc/backend/csharp/open-api/) # Attribute reference > Every Arc attribute you write on a C# type, what it may be placed on, and what it changes. Arc discovers what your application exposes from attributes on your own types. This is the whole set you write, grouped by what it concerns. Attributes that come from Chronicle or Fundamentals rather than Arc are marked as such, because they arrive with a different package. For the Kotlin and Java equivalents, see its [annotation reference](/arc/backend/kotlin/reference/annotations/) — the concepts match, the spellings do not. ## Artifacts | Attribute | Valid on | Effect | | --- | --- | --- | | `[Command]` | class | Marks a record or class as a command. Arc exposes it, validates and authorizes it, and calls its `Handle()` method. | | `[ReadModel]` | class | Marks a type as a read model. Eligible public or internal static methods become queries; private helpers do not. | ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } ``` ## Routing and transport | Attribute | Valid on | Effect | | --- | --- | --- | | `[Path]` (`Cratis.Arc.Queries.ModelBound`) | model-bound command, read model or query method | Overrides the conventional HTTP route. A command’s `/validate` endpoint uses the same path plus `/validate`. The explicit path is not prefixed or lowercased. | | `[QueryHttpMethod]` | read model, query method | Sets the HTTP method the **generated proxy** uses by default (GET, QUERY, or Auto). The server accepts GET and, unless `GeneratedApis.EnableQueryHttpMethod` is `false`, QUERY regardless of this attribute. | | `[FromRequest]` | parameter, property | Binds a value from several request sources rather than the default one. | | `[ExplicitOffset]` (`Cratis.Arc.Commands`) | property, field | Requires JSON DateTime/DateTimeOffset input to include an explicit offset or Z. Nullable forms accept null. Use `[property: ExplicitOffset]` on a positional record; see [input binding](/arc/backend/csharp/commands/input-binding/). | A route you do not override is derived, so `[Path]` is for the cases where the derived route is wrong for you — not something to apply everywhere. A command’s explicit `[Path("/api/orders/create")]` stays stable even if another command is added to its namespace; its generated TypeScript proxy calls that same path. Import `Cratis.Arc.Queries.ModelBound` for both commands and queries. A command’s path must be declared on that command type, not inherited from a base type. Avoid reusing an explicit path for another command or for a conventional command route; route collisions can make endpoints ambiguous. A command path is a literal route: route templates such as `{id}` are not supported for commands, and it should not end with `/` (the validation endpoint appends `/validate`). ## Discovery and API descriptions | Attribute | Valid on | Effect | | --- | --- | --- | | `[ExcludeFromDiscovery]` (`Cratis.Arc`) | command type, read model type, query method, controller or controller action | Omits the marked operation from `/.cratis/commands` or `/.cratis/queries` and generated OpenAPI descriptions (including command validation endpoints). | The marker is inherited: marking a base command type or read model also hides every type that derives from it. This differs from a command’s `[Path]`, which is not inherited. This marker **does not unmap the HTTP endpoint or authorize it**. Protect it with `[Authorize]`, a trusted ingress, or both; do not rely on hiding metadata as a security boundary. Controller-based commands are not in the command catalog, but marked controller actions are omitted from MVC’s API descriptions. ## Authorization | Attribute | Valid on | Effect | | --- | --- | --- | | `[Authorize]` | class, method | Requires an authenticated caller, optionally satisfying a policy. | | `[Roles]` | class, method | Requires **at least one** of the listed roles. | | `[AllowAnonymous]` | class, method | Opens a specific artifact or operation back up. | Apply them at class level to cover everything and override per method for the exceptions. An artifact with no authorization attribute is open to any caller unless your ASP.NET Core host configures a [fallback policy](/arc/backend/csharp/asp-net-core/authorization/), so protection is something you declare, not a default. A failure surfaces as `isAuthorized: false` in the result rather than as an exception, so the frontend can react to it. ## Validation | Attribute | Valid on | Effect | | --- | --- | --- | | `[IgnoreValidation]` | controller, action | Skips Arc’s validation filter for an ASP.NET Core controller or action, leaving ASP.NET Core’s default model validation behavior. It has no effect on model-bound commands and queries. | Most validation is declared in a `CommandValidator` or `ConceptValidator` rather than with an attribute. See [validation](/arc/backend/csharp/commands/validation/). ## Hosting and registration | Attribute | Valid on | Effect | | --- | --- | --- | | `[IgnoreAutoRegistration]` | class | Keeps a type out of Arc’s conventional discovery. | | `[AspNetResult]` | controller, action | Returns an ASP.NET Core controller action’s result directly instead of wrapping it in Arc’s result envelope. It applies to controller-based endpoints only. | `[AspNetResult]` opts out of the envelope the generated client expects, so a proxy will not consume that endpoint in the usual way. Reach for it when an endpoint exists for something other than your own frontend. ## With the Chronicle integration These arrive with Chronicle rather than Arc itself, and only apply when the [Chronicle integration](/arc/backend/csharp/chronicle/) is in use. | Attribute | Valid on | Effect | | --- | --- | --- | | `[EventType]` | class | Marks a record as an event. Takes no arguments for a new event. | | `[Key]` (`Cratis.Chronicle.Keys`) | property, positional record parameter | Identifies the event source a command acts on. Without Chronicle, Arc instead reads `System.ComponentModel.DataAnnotations.KeyAttribute` as the command key; Chronicle ignores that one, so unless the command has an `EventSourceId`-typed property, it can silently resolve no event source; [ARCCHR0008](/arc/backend/csharp/chronicle/code-analysis/arcchr0008/) warns about it. | | `[NotAudited]` | class, struct, property, parameter | Keeps a secret out of the causation chain written with every event. | | `[ExecuteCommandsAsSystem]` | class | Runs a reactor’s returned commands without a user principal. | `[NotAudited]` is the one worth knowing before you need it: a command’s property values are recorded in the causation chain of every event it appends, so an unmarked token or password reaches the event log in clear text and stays there. It is not interchangeable with Chronicle’s `[PII]`, which encrypts and enrolls a value in erasure — that is the right marking for personal data and the wrong one for a secret. ## Related * [Code analysis](/arc/backend/csharp/code-analysis/) — the analyzers that check these are used correctly. * [Commands](/arc/backend/csharp/commands/) and [Queries](/arc/backend/csharp/queries/) — what the artifact attributes actually enable. # Authorizing commands and queries > Restrict who can run a command or read a query with role-based authorization, applied at the boundary. **Goal:** only certain users may perform an action or see certain data — “only a librarian can register an author,” “only an admin sees the audit list.” You want that enforced declaratively, not with `if` checks scattered through your logic. ## Authorize at the boundary, not in the logic Authorization is a cross-cutting concern: it belongs at the edge, applied as an attribute, so your `Handle()` methods and read models stay focused on behavior. Arc enforces role attributes on **both** commands and query methods when invoked through its pipelines. Direct C# calls to `Handle()` or a static query bypass these checks. Use attributes from `Cratis.Arc.Authorization`; `[Roles]` requires authentication and at least one listed role. `[Authorize(Policy = "...")]` is enforced too, once you [register the named policy](/arc/backend/csharp/core/authorization/#register-a-named-policy); it requires authentication unless the policy registration explicitly opts into guest evaluation. Named `AuthenticationSchemes` work only on the [ASP.NET Core host](/arc/backend/csharp/asp-net-core/authorization/); the standalone Arc host fails at startup rather than ignoring them. ## Protect a command Put `[Roles(...)]` on the `[Command]` record. Arc checks the caller’s roles before the command runs. These are illustrative type fragments using application-owned `AuthorId`, `AuthorName`, `UserRole`, and the optional MongoDB integration; they do not require Chronicle: ```csharp [Command] [Roles(nameof(UserRole.Librarian))] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } ``` Keep authorization at the command boundary The direct insert remains supported. For inline writes that can be declared before execution, prefer a pure `Handle()` returning a [command operation](/arc/backend/csharp/commands/operations/), with the write performed by `Execute()`. Keep `[Roles]` on the command; operations do not replace pipeline authorization. ## Protect a query Query methods on a read model take the same attribute, so the read side is gated too: ```csharp [ReadModel] public record Author([property: Key] AuthorId Id, AuthorName Name) { [Roles(nameof(UserRole.Librarian))] public static ISubject> AllAuthors(IMongoCollection collection) => collection.Observe(); } ``` ## Who the user is Roles used by the evaluator come from the authenticated `ClaimsPrincipal`, not the identity details. Arc integrates with ASP.NET Core authentication; its lightweight host has its own handlers. `IProvideIdentityDetails` supplies application-specific presentation details but does not add trusted claims or authorize every operation. Read the principal through `ICurrentPrincipalAccessor` from `Cratis.Arc.Authorization`. See the [Identity](/arc/backend/csharp/identity/) section for setting that up, and for generating a principal during local development so you can exercise authorized endpoints without a full login. ## Notes * **Multi-tenancy** requires three separate controls: [tenant selection](/arc/backend/csharp/tenancy/), application membership authorization, and integration-specific storage isolation. Selecting a tenant ID alone does not restrict a user to their own data. * The generated TypeScript proxies respect the same rules — an unauthorized call fails the same way it would from any client. ## See also * [Identity](/arc/backend/csharp/identity/) — authentication, identity details, and local-dev principals. * [Tenancy](/arc/backend/csharp/tenancy/) — isolating data per tenant. * [Commands](/arc/backend/csharp/commands/) and [Queries](/arc/backend/csharp/queries/) — the full model. # Integrate with Chronicle > Add Chronicle's event-sourced write path to an Arc application while keeping Arc's commands, queries, identity, tenancy, and generated proxies. `Cratis.Arc.Chronicle` connects Arc’s command pipeline to [Cratis Chronicle](https://github.com/Cratis/Chronicle). Commands can return events for automatic appending, resolve projected state as dependencies, and use tenant context to select an event-store namespace. Arc continues to generate TypeScript clients from the command and query contracts. Arc does not require Chronicle, and Chronicle does not require Arc. That independence is useful for adoption and bounded current-state slices. In a full Cratis information system, though, this integration is the natural pairing: Arc gives the CQRS boundary and Chronicle keeps the event-sourced facts underneath it. ## How the two fit together The frameworks meet at one seam: an Arc **command** appends a Chronicle **event**, a Chronicle **projection** folds events into a **read model**, and an Arc **query** serves that read model back — with the generated TypeScript proxy carrying both ends to React. ``` command returns an event projection query React [Command] record · Handle() Chronicle event log [ReadModel] · materialized to the sink ``` Reading that loop in code: * **A command writes by *returning*.** A `[Command]` record carries the command’s inputs as its **properties**, and the decision lives in a `Handle()` method on the record. Whatever `Handle()` **returns** is what Chronicle does with it — return an `[EventType]` event and it’s appended; the [return signature](/arc/backend/csharp/chronicle/commands/events/) (a single event, several, a tuple, a `Result<,>`, or nothing) decides the outcome. Returning events is the recommended path; [transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/) documents explicit append alternatives and their boundaries. * **The event source id picks the stream.** Every event belongs to one **event source** — one entity’s stream of history. Chronicle resolves that id from the command: a `[Key]` parameter, an `EventSourceId` or `EventSourceId`-derived property, or `ICanProvideEventSourceId`. See [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/). * **The read model is projected, then queried.** A `[ReadModel]` record declares the shape you want; `[FromEvent]`, `[SetFrom]`, and `[SetValue]` map events onto its properties and Chronicle keeps it **materialized** in the configured sink (MongoDB by default). An Arc query — a static method on the read model, often returning an observable so the UI stays live — serves it through the generated proxy. See [Read Models](/arc/backend/csharp/chronicle/read-models/). * **The loop closes: a command can read the state it helped build.** When a decision depends on what’s already true, the command doesn’t query for it — Arc resolves the read model for the command’s key and hands it to the validator, `Provide()`, or `Handle()` as a parameter. See [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/). So the round-trip is: a fact happens (command → event), it’s folded into state (projection → read model), and the UI reads it (query → proxy) — and the next command can read that same state to decide. Each piece is one of the topics below. ## What it provides Without this package, Arc and Chronicle are independent. With it: * **Commands return events** — `Handle()` methods on commands can return event records directly; the package appends them to the correct event log automatically. * **Event source resolution** — command input metadata selects the stream identity; authenticated identity and ordinary query binding are separate. * **Read models backed by projections** — Chronicle backing artifacts supply materialized or passive state. A materialized query can lag behind an append. * **Tenant-aware event stores** — each tenant’s event log and projections are namespaced automatically, matching Arc’s tenancy model. * **Compliance integration** — read-model interception requests PII release; append subjects come from command/event metadata, not automatically from authenticated identity. See the compliance reference for failure and passive-path limits. * **Aggregate support** — aggregate roots are discoverable and invocable via the standard command pipeline, with Chronicle managing the event stream and rehydration. * **Current state as a command dependency** — a command’s `CommandValidator<>`, `Provide()`, and `Handle()` can each take the read model Chronicle projected for the command’s key as an ordinary parameter, so a state-dependent decision needs no query round-trip. ## Topics | Topic | Description | | --- | --- | | [Aggregates](/arc/backend/csharp/chronicle/aggregates/) | Working with aggregate roots and event sourcing. | | [Add event sourcing to an Arc slice](/arc/backend/csharp/chronicle/add-event-sourcing/) | Move one database-backed slice to Chronicle while keeping its query and React screen in place. | | [Cratis Package](/arc/backend/csharp/chronicle/cratis-package/) | The convenience package for Arc + Chronicle applications. | | [React to an event](/arc/backend/csharp/chronicle/react-to-an-event/) | Run side effects or follow-up commands from Chronicle events with reactors. | | [Commands](/arc/backend/csharp/chronicle/commands/) | Returning events from commands, event source id resolution, and concurrency scoping. | | [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/) | Command identity selection, dependency timing, and the separate query-binding boundary. | | [Read Models](/arc/backend/csharp/chronicle/read-models/) | What makes a read model injectable into a command, how it is resolved by key, and what happens when it does not exist. | | [Tenancy](/arc/backend/csharp/chronicle/tenancy/) | Tenant-aware namespaces for event stores and projections. | | [Validation](/arc/backend/csharp/chronicle/validation/) | Validating a command against the state Chronicle already projected for its key. | | [Compliance](/arc/backend/csharp/chronicle/compliance/) | PII decryption on read models and compliance subject resolution on commands. | | [Code Analysis](/arc/backend/csharp/chronicle/code-analysis/) | Diagnostics and analyzers specific to the Chronicle integration. | # Add event sourcing to an Arc slice > Move an Arc slice's writes to Chronicle while deliberately preserving its query and frontend contracts. You have a complete, typed, live full-stack app from the Arc tutorial — and so far it used a database directly. This page shows the write-side move that puts [Chronicle](/chronicle/) underneath the same CQRS boundary. For information systems, this is the direction we usually recommend: keep the Arc command/query shape, and let Chronicle store the facts. You can preserve your query signatures, generated proxies, and React screens. That requires preserving the read model’s storage contract as well as changing the write side. This outline follows the tutorial’s **MongoDB branch**, where Chronicle’s projection can materialize the read model into the collection the unchanged query already observes. On the EF Core branch, `AllAuthors` reads `LibraryDbContext`; keeping that query unchanged would need Chronicle to materialize the read model into the same relational table, and this page does not cover that path. Decide where the projected read model is stored before you promise the query stays as it is. ## Before changing the slice This is a migration outline, not a runnable host checkpoint. First: * Add the **`Cratis` package** to the ASP.NET Core host and follow its [basic setup](/arc/backend/csharp/chronicle/cratis-package/#basic-setup): `builder.AddCratis()` before building, then `app.UseCratis()`. This supplies both Arc and Chronicle’s ASP.NET Core integration. If you bring your own authentication, retain the `Cratis` package reference and follow its [split-registration example](/arc/backend/csharp/chronicle/cratis-package/#running-arc-or-chronicle-on-their-own) instead. `Cratis.Arc.Chronicle` alone does not establish the ASP.NET Core extension surface used by that example. * Run a compatible Chronicle server and configure its connection, event store, namespace, and read-model sink. The client registration does not start the server. * For the MongoDB query below, separately install/configure `Cratis.Arc.MongoDB`. Align its database, collection naming, key mapping, serialization, and tenant mapping with Chronicle’s sink. Sharing an application host does not establish that alignment. * Decide how existing rows become history: import explicit initial facts, backfill, or cut over to a new model. Adding a projection does not turn existing rows into events. Plan replay and prevent old direct writes from racing the new projection. After the change, append one author and verify its event-source id, projected key, and query result agree. Expect asynchronously materialized state to become visible after projection processing, not necessarily when the command response arrives. If you drew the database-backed slice as an **[event model](/event-modeling/)**, the model barely changes here. The screen, the command, the `AuthorRegistered` fact, the read model, and the query all stay put. What changes is *where the fact lives* — a database row becomes a real, stored event — and that unlocks one genuinely new block: a **reactor** (frame `06`), automation that fires *when a fact happens*. ``` UI/A: AuthorsUI/A: AuthorsUI/A: AuthorsC/RM: AuthorsC/RM: AuthorsStream: Authors AddAuthor RegisterAuthor id: uuid, name: string AuthorRegistered name: string Author Authors WelcomeNewAuthors ``` Frames `01`–`05` are unchanged from the intro — the fact at `03` is just stored differently now. Frame `06` is the new part: a reactor, which you couldn’t have before because there were no stored events to react to. Let’s prove it on the very first slice. ## What changes, and what doesn’t Set the two versions of the author slice side by side. **The command stops writing a document and starts recording a fact.** `Handle()` returns the event that happened instead of inserting: ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public AuthorRegistered Handle() => new(Name); // returns the fact, doesn't write } [EventType] public record AuthorRegistered(AuthorName Name); ``` The event is an immutable, past-tense fact. `[EventType]` carries **no name argument** — Chronicle uses the type name, `AuthorRegistered`, as its identity. For Chronicle to use an author’s id as the key of their event stream, derive the identity from `EventSourceId` (in `Cratis.Chronicle.Events`): ```csharp public record AuthorId(Guid Value) : EventSourceId(Value) { public static readonly AuthorId NotSet = new(Guid.Empty); public static AuthorId New() => new(Guid.NewGuid()); public static implicit operator AuthorId(Guid value) => new(value); } ``` **The read model stops being filled by the command and starts being filled by a projection.** Mark it `[FromEvent]` and Chronicle folds the event onto it — **AutoMap** matches `AuthorRegistered.Name` straight onto `Author.Name`, so you write no update code: ```csharp [ReadModel] [FromEvent] public record Author(AuthorId Id, AuthorName Name) { // The query is UNCHANGED from chapter 1. public static ISubject> AllAuthors(IMongoCollection collection) => collection.Observe(); } ``` Those are the slice’s type changes. With bootstrap, existing-data treatment, and sink alignment in place, the intended contract is: | | Over a database | With Chronicle | | --- | --- | --- | | What `Handle()` does | writes a document | returns an event | | What fills the read model | the command, directly | a projection over the event | | What you can read | current state | current state **and full history** | | The query method | `AllAuthors` | `AllAuthors` — *identical* | | The generated proxies & React | as built | *identical* | ## The frontend doesn’t notice The query is the same method, so the generated proxy is the same type, so the screen you wrote in chapter 1 keeps working untouched — it’s still subscribed to the read model, which a projection now keeps current instead of the command: * C# — backend ```csharp // the read model is now event-sourced — the query signature is unchanged public static ISubject> AllAuthors(IMongoCollection collection) => collection.Observe(); ``` * TypeScript — frontend ```tsx // not one character changes const [authors] = AllAuthors.use(); ``` ## What the events unlock Now that state changes are recorded as facts, you get things a plain database can’t give you: * **A history of recorded facts** — with explicit retention and [causation privacy](/arc/backend/csharp/chronicle/commands/causation/) decisions; it does not automatically audit writes outside Chronicle. * **Rebuild read models from history** — model a brand-new view of the past by replaying the events into it. * **Reactors** — automate a follow-up *when a fact happens*. A reactor is a class marked `IReactor`; the method’s first parameter is the event it reacts to: ```csharp public class WelcomeNewAuthors(INotificationService notifications) : IReactor { [OnceOnly] public Task AuthorRegistered(AuthorRegistered @event, EventContext context) => notifications.Notify($"Welcome aboard, {@event.Name}!"); } ``` `[OnceOnly]` skips this notification handler during replay; it does not deduplicate retries. The notification service must tolerate duplicate delivery. These fragments assume the Chronicle event, projection, and reactor namespaces and your existing domain types. Note This is why the live-screen chapter was just *observable queries* and not “make it react” — reactions fire on **events**, and you didn’t have any until now. Reactors are an event-sourcing feature. ## Why this is usually the better backbone Storing current state directly is simpler for bounded CRUD surfaces and adoption steps. Event sourcing earns its keep for information systems because history, auditability, replay, integration, and process insight show up over time. [Why Event Sourcing](/chronicle/why-event-sourcing/) explains why we treat it as the default, and [When to use event sourcing](/chronicle/concepts/when-to-use-event-sourcing/) names the exceptions. You also saw why the boundary matters: the read side and the entire frontend came along unchanged. * **The event-sourcing side, in depth** — the [Chronicle tutorial](/chronicle/tutorial/) builds the same library model one event-sourcing concept at a time. * **The two together, end to end** — the [Cratis Stack tour](/cratis-stack/) and the [full-stack capstone](/build-a-full-app/) put Arc, Chronicle, and Components together on a real feature. * **Adopt it incrementally** — [Adopting Cratis](/adopting-cratis/) walks through moving an existing Arc app’s write side to Chronicle, one slice at a time. That’s the library slice — built full-stack and type-safe, with CQRS at the boundary and Chronicle underneath when the slice belongs in the event-sourced model. You have the model; go build your own. # Aggregates > Use an aggregate root when a command's decision must hold against the entity's own history — Arc resolves it from the command's key and commits what it applies. Most commands can decide from what they carry plus a projected read model. Some can’t. “Withdraw 200” has to be checked against *this* account’s actual history, and it has to stay correct when two withdrawals arrive at once — a read model that lags by a few milliseconds will happily approve both. That is what an aggregate root is for. It rehydrates from the entity’s own event stream, applies new events under its own rules, and commits them as one unit. Where a read model is a *snapshot you read*, an aggregate root is *the thing that decides and records*. ## The difference in one line ```csharp [Command] public record WithdrawFunds([Key] Guid AccountId, decimal Amount) { public async Task Handle(Account account) { await account.Withdraw(Amount); return await account.Commit(); } } ``` This command fragment assumes an `Account : AggregateRoot` with an asynchronous `Withdraw` method, plus imports for `Cratis.Arc.Chronicle.Aggregates`, `Cratis.Arc.Commands.ModelBound`, and `Cratis.Chronicle.Keys`. Arc loads its history for `AccountId` before `Handle()` runs. Returning `Commit()` propagates aggregate `Failed(...)` results, but commits the shared transaction at that point. Automatic completion is also supported; it does not collect those aggregate failures. See [commit boundaries and the current limitation](/arc/backend/csharp/chronicle/aggregates/defining-an-aggregate-root/#automatic-completion-and-its-current-limitation). ## Which one do I reach for | | Read model | Aggregate root | | --- | --- | --- | | Answers | “what does this look like now?” | “is this change allowed, and what happened?” | | Built from | materialized events, or on-demand passive state | events, replayed on resolution | | Consistency | depends on backing; a snapshot is not a concurrency lock | depends on captured revision and append-time enforcement | | Can emit events | no | yes | | Reach for it when | gating on projected state, computing inputs | an invariant must hold under concurrency | They compose. Validate against a read model to give the user a fast, specific message, and let the aggregate enforce the invariant that actually must not break. See [Read models](/arc/backend/csharp/chronicle/read-models/). ## How Arc wires it up The same key resolution that picks a read model picks the aggregate — a `[Key]` property, an `EventSourceId` or `EventSourceId`-derived property, or `ICanProvideEventSourceId`. See [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/). * **Discovered automatically** — every type implementing `IAggregateRoot` is registered without configuration. * **Resolved per command** — the instance is command-scoped and bound to that command’s event source id, rehydrated from its stream on resolution. * **Committed for you** — applied events are enrolled in the command’s transaction and committed on success, rolled back on failure. A command with no declared key receives a generated creation identity. A declared but unusable identity can fail with `UnableToResolveAggregateRootFromCommandContext`. Rehydration alone does not prove a concurrency guarantee: verify the aggregate revision and append behavior for the invariant you enforce. ## Topics | Topic | Description | | --- | --- | | [Defining an aggregate root](/arc/backend/csharp/chronicle/aggregates/defining-an-aggregate-root/) | Writing the class itself — applying events, `On` methods, and how state is rebuilt. | | [Aggregate roots in commands](/arc/backend/csharp/chronicle/aggregates/injecting-into-commands/) | Taking one as a `Handle()` dependency, key resolution, and lifetime. | # Defining an aggregate root > Rebuild aggregate state with event handlers, apply new facts, and choose the correct commit boundary. An aggregate makes a decision from an entity’s event history. In Arc’s optional Chronicle integration, derive from `Cratis.Arc.Chronicle.Aggregates.AggregateRoot`, apply facts with `Apply`, and rebuild state in event handlers. The factory supplies the mutation context; do not construct an aggregate yourself for production use. ## State that survives replay This complete aggregate/type example records order quantities. `OnItemAdded` handles both replayed history and newly applied events, so the next decision sees the updated quantity. ```csharp using Cratis.Arc.Chronicle.Aggregates; using Cratis.Chronicle.Events; using Cratis.Concepts; public record OrderId(Guid Value) : EventSourceId(Value) { public static readonly OrderId NotSet = new(Guid.Empty); public static OrderId New() => new(Guid.NewGuid()); public static implicit operator OrderId(Guid value) => new(value); } public record ProductId(Guid Value) : EventSourceId(Value) { public static readonly ProductId NotSet = new(Guid.Empty); public static ProductId New() => new(Guid.NewGuid()); public static implicit operator ProductId(Guid value) => new(value); } public record Quantity(int Value) : ConceptAs(Value) { public static readonly Quantity NotSet = new(0); public static implicit operator Quantity(int value) => new(value); } public class Order : AggregateRoot { int _quantity; public async Task AddItem(ProductId productId, Quantity quantity) { if (quantity <= 0 || quantity > 100 - _quantity) { Failed("Quantity must be positive and the order total must not exceed 100."); return; } await Apply(new ItemAdded(productId, quantity)); } public void OnItemAdded(ItemAdded @event) => _quantity += @event.Quantity; } /// /// Records a product quantity added to an order. /// [EventType] public record ItemAdded(ProductId ProductId, Quantity Quantity); ``` Keep each concept in its own file in the application. `OrderId` and `ProductId` distinguish the target order from the referenced product; `Quantity` carries the amount being added. The event keeps the foreign product reference, while the order id lives in event context. The bound assumes valid replayed state between 0 and 100; subtracting the current quantity avoids overflowing when the input is a very large positive integer. `Apply` is asynchronous: always await it. Do not also return the same `ItemAdded` from a command; that would declare another append. Event handlers rebuild internal state only. Do not send notifications, call external systems, or append more events from a replay handler. Aggregate `Apply()` does not forward the command-context compliance subject. Its events use Chronicle’s event-level subject resolution and fallback, even when the command sets or returns a subject. See the [aggregate subject limitation](/arc/backend/csharp/chronicle/compliance/subject/#aggregate-apply-limitation) before choosing the encryption identity. ## Declare the event source An aggregate root that always works within one registered event source definition declares it with `[EventSource]`, the same attribute commands use. The definition supplies the event source type, the optional stream name becomes the aggregate’s event stream type, and every event the aggregate appends records the definition in `EventContext.EventSource`. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.EventSources; [EventSource] [EventStream("transactions")] public class Account : IEventSource; [EventSource("transactions")] public class AccountAggregate : AggregateRoot { // handlers and commands as usual } ``` Rehydration then reads the events of the declared event source type and stream type, as described in [Which events rehydration reads](#which-events-rehydration-reads). Arc rejects an aggregate that names a stream its definition does not declare, a `Get` call asking for a different event source type, or an `[EventStreamType]` that contradicts the declared stream. The legacy attributes keep working for aggregates that do not declare a definition. ## Which events rehydration reads An aggregate root rebuilds its state from the events it handles, and its commit fails with a concurrency violation if an event arrived since it was loaded in any stream it read from. Whether it declares an event source decides how wide that is. | | Without `[EventSource]` | With `[EventSource]` | | --- | --- | --- | | Rehydration applies | every handled event for the event source id, in any stream | handled events of its own event source type, event stream type and event stream id | | The commit rejects a concurrent append | to any stream for the event source id: handled event types for an existing aggregate, any event type for a new aggregate or one without handlers | to its event source type and event stream type, in the event stream id passed to `Get` or else in any | | Its own events are appended with | its event stream type, event stream id and event source type | the same, plus the declared event source | An aggregate without a declaration therefore picks up handled events appended for its id anywhere, for example straight to the event log or by another aggregate type, and its commit protects all of them. For an aggregate that already existed when loaded and has handlers, only a handled event type appended for the same id after loading causes a concurrency violation; a new aggregate keeps the any-event-type guard so a competing creation cannot succeed twice. Declare an event source when the aggregate owns one stream and other streams for the same id should neither shape its state nor block its commit. ## Event handler signatures Method names are conventional, not the dispatch key. The first parameter identifies a registered event type. Explicitly registered event types work for both handlers and `Apply`, even without an `[EventType]` attribute. These are signature fragments, not method implementations: ```csharp void On(TEvent @event) Task On(TEvent @event) void On(TEvent @event, EventContext context) Task On(TEvent @event, EventContext context) ``` [ARCCHR0001](/arc/backend/csharp/chronicle/code-analysis/arcchr0001/) checks recognized handler candidates. A clean analyzer result is not proof that every intended handler was discovered; test replay and a subsequent mutation. ## Injected read models are dependencies, not aggregate replay state Constructor injection can supply a registered read model, including an on-demand passive model. Its resolver controls its key and state. The aggregate factory’s explicit id does not automatically become a new command context for those dependencies. An injected model does not continually update when this aggregate applies events. Use aggregate handlers for state that must evolve during replay and mutation. For ordinary snapshot context, use [read-model injection](/arc/backend/csharp/chronicle/read-models/). Chronicle’s projection and reducer interfaces are `IProjectionFor` and `IReducerFor`; they are not a special aggregate-state API. ## Reporting failure and committing `Failed(message, severity)` accumulates aggregate validation results. `AggregateRoot.Commit()` checks them: an Error prevents that commit; Warning and Information results are included without blocking it. For the `Order` above, return the commit result so Arc can propagate these failures: ```csharp using Cratis.Arc.Chronicle.Aggregates; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] public record AddItemToOrder(OrderId OrderId, ProductId ProductId, Quantity Quantity) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => OrderId; public async Task Handle(Order order) { await order.AddItem(ProductId, Quantity); return await order.Commit(); } } ``` Both ids derive from `EventSourceId`, so `GetEventSourceId()` explicitly selects the order as the command target. Arc translates the returned result into the command outcome, including validation, constraint, concurrency, and append failures. Explicit commit is an early boundary Inside a command, `Commit()` commits the **shared command unit of work**, not just this aggregate. Previously enrolled events commit at that point. A later command failure cannot undo them. Return immediately after committing and do not treat subsequent work as part of that atomic batch. ## Automatic completion and its current limitation The command pipeline can commit enrolled aggregate events automatically when the command result succeeds. That is useful when the command’s own result carries all rejection decisions. However, automatic completion commits the unit of work directly: it does **not** call each aggregate’s `Commit()` or collect its private `Failed(...)` results. Do not replace the explicit commit in this example with a `Task`-only handler. In particular, an aggregate that applies an event and later calls `Failed(...)` can leave enrolled events while the command still appears successful. Propagate failure through the command result before completion, or retain the explicit commit/result path above and accept its boundary. Multi-aggregate failure aggregation needs particular care; one aggregate’s commit does not inspect another aggregate’s private validation list. Rehydration alone also does not prove a concurrency invariant. Test the revision captured by the aggregate path and the competing append behavior for your client/server versions; use append-time constraints or [exact concurrency scopes](/arc/backend/csharp/chronicle/commands/events/#events-with-exact-concurrency-scopes) when appropriate. ## Using the factory outside a command This service fragment uses the same `Order` and returns its result to its caller: ```csharp using Cratis.Arc.Chronicle.Aggregates; public class OrderService(IAggregateRootFactory aggregates) { public async Task AddItem( OrderId orderId, ProductId productId, Quantity quantity) { var order = await aggregates.Get(orderId); await order.AddItem(productId, quantity); return await order.Commit(); } } ``` The caller must inspect the result and manage the surrounding lifetime. This fragment is not an HTTP endpoint and does not supply Arc command validation or authorization by itself. For the full boundary, see [Transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/); for injection and identity timing, see [Aggregate roots in commands](/arc/backend/csharp/chronicle/aggregates/injecting-into-commands/). # Aggregate roots in commands > Resolve aggregates by the command's input identity, await mutations, and choose an explicit or automatic commit boundary. With the Chronicle integration registered, Arc discovers `IAggregateRoot` implementations and registers them as command-scoped services. Declare one as a `Handle()` parameter: Arc reads the command-context event source id and calls `IAggregateRootFactory.Get()` to rehydrate it before invoking your handler. ## Inject and await a mutation This command fragment uses the complete `Order` implementation from [Defining an aggregate root](/arc/backend/csharp/chronicle/aggregates/defining-an-aggregate-root/): ```csharp using Cratis.Arc.Chronicle.Aggregates; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Keys; [Command] public record AddItemToOrder([Key] Guid OrderId, Guid ProductId, int Quantity) { public async Task Handle(Order order) { await order.AddItem(ProductId, Quantity); return await order.Commit(); } } ``` The aggregate applies its own events. Do not return copies of those events from `Handle()`, and do not ignore a mutation’s `Task`. This example explicitly commits because `Order` reports errors through `Failed(...)`. It returns immediately afterward: explicit commit finalizes the shared command unit of work. Automatic completion does not collect private aggregate failure lists. Read [the commit limitation](/arc/backend/csharp/chronicle/aggregates/defining-an-aggregate-root/#automatic-completion-and-its-current-limitation) before changing this to a `Task`-only handler. ## Which identity loads the aggregate [Command identity resolution](/arc/backend/csharp/chronicle/resolving-event-source-id/) happens before dependency construction: * `ICanProvideEventSourceId` takes precedence. * Otherwise, use one unambiguous `EventSourceId`, `EventSourceId`-derived, or Chronicle `[Key]` property. * A keyless creation command receives a generated identity and can resolve a new aggregate with no history. * A declared but unusable identity, such as `EventSourceId.Unspecified`, cannot resolve an aggregate and produces `UnableToResolveAggregateRootFromCommandContext`. A returned tuple identity arrives **after** the aggregate has loaded. It can select the target of returned events, but cannot retarget that aggregate’s history or enrolled events. Generate or supply the identity before resolution when a creation command needs an injected aggregate and a known id. ## Multiple aggregates Automatic injection selects one identity, not one CLR type. Different aggregate types can each resolve under that identity. Repeated resolution of the same type in a command shares its scoped instance. For a second identity, use the factory explicitly. This is a lookup fragment inside an asynchronous handler; it does not commit or define a transfer operation: ```csharp var destination = await aggregateRootFactory.Get(destinationId); ``` Here `aggregateRootFactory` is an `IAggregateRootFactory`, `destinationId` is an `EventSourceId`, and `Account` is your aggregate type. Factory loading does not replace the command context used by an aggregate’s injected read-model dependencies. Make cross-source transaction and failure handling explicit rather than assuming multiple objects imply one validated aggregate boundary. ## Lifetime and transaction An injected instance is resolved once per command scope and rehydrated on resolution. Applied events enroll in the command’s unit of work. If the pipeline sees a failed command before commit, it rolls back pending events. If the command succeeds, it completes the pending transaction unless an explicit commit already completed it. This lifetime does not give aggregate `Failed(...)` calls automatic propagation, undo an earlier manual commit, or make external service calls transactional. See [Transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/). ## What an aggregate guards Arc reads the event source’s tail when the aggregate is resolved, before `Handle()` runs, and every event the aggregate applies carries that expectation. A new aggregate expects an empty history, so two commands that both create it cannot both succeed. An aggregate guards its own event source and the event types it handles: * It does not guard a rule across aggregates. Two aggregates for two keys each guard only their own source. * It has no snapshots: every command reads the key’s handled events again, so a key with a long history gets slower. * Under read protection `On`, it cannot be combined with a plain read of the **same** event source in one command (`ReadAndAggregateOnSameSource`); a read of a different source is fine. Outside `On`, it cannot be combined with `[ProtectedDecision]` reads. * A handler that ignores the result of `Commit()` reports success when the aggregate rejected the command. Return it, as above. [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) compares aggregates with constraints, read protection and stream decisions. See [Rich aggregate](/arc/backend/csharp/chronicle/consistency/scenarios/aggregate/) for how an aggregate behaves under `On`. Its [pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/#an-aggregate-commit-whose-result-is-ignored) show the silent failures. ## Verify the boundary A useful aggregate spec reconstructs history, applies another event, and checks the resulting state and exact append count. Add cases for `Apply` followed by `Failed`, failure before and after explicit commit, and competing commands. A build proves signatures, not those behaviors. # Code analysis > Build-time diagnostics for Arc's optional Chronicle integration, including their heuristics and boundaries. These `ARCCHR####` Roslyn diagnostics belong to **Arc’s Chronicle integration**, not to standalone Arc or every project that references pure Chronicle. They catch integration mistakes while you build, so a misplaced key, ambiguous identity, or unsupported handler shape is visible before a request reaches production. ## Rules overview | Rule | Severity | What it checks | | --- | --- | --- | | [ARCCHR0001](/arc/backend/csharp/chronicle/code-analysis/arcchr0001/) | Error | Recognized aggregate event-handler candidates have supported signatures. | | [ARCCHR0002](/arc/backend/csharp/chronicle/code-analysis/arcchr0002/) | Warning | Multiple candidate command identities without an explicit provider/recognized return exemption. | | [ARCCHR0003](/arc/backend/csharp/chronicle/code-analysis/arcchr0003/) | Warning | Reactor access to the default event log instead of returned side effects. | | [ARCCHR0004](/arc/backend/csharp/chronicle/code-analysis/arcchr0004/) | Warning | Redundant explicit id on `[EventType]`: empty, or equal to the type name. | | [ARCCHR0005](/arc/backend/csharp/chronicle/code-analysis/arcchr0005/) | Warning | Chronicle usage and `AddCratisArc` appear in one project without integration setup. | | [ARCCHR0006](/arc/backend/csharp/chronicle/code-analysis/arcchr0006/) | Warning | Manual reactor command execution without method- or class-level `[OnceOnly]`. | | [ARCCHR0007](/arc/backend/csharp/chronicle/code-analysis/arcchr0007/) | Warning | A command `Handle()` or `Provide()` parameter is `IEventLog` or an implementing type. | | [ARCCHR0008](/arc/backend/csharp/chronicle/code-analysis/arcchr0008/) | Warning | Data annotations `[Key]` used where Chronicle key resolution applies. | | [ARCCHR0009](/arc/backend/csharp/chronicle/code-analysis/arcchr0009/) | Warning | Likely secret command values lack audit exclusion metadata. | | [ARCCHR0010](/arc/backend/csharp/chronicle/code-analysis/arcchr0010/) | Warning | A keyless command returns a raw Guid beside statically identifiable untargeted events. | | [ARCCHR0011](/arc/backend/csharp/chronicle/code-analysis/arcchr0011/) | Info | Under Off, plain read-model parameters or `IReadModels.GetInstanceById` calls in event-producing command or validator code. | | [ARCCHR0012](/arc/backend/csharp/chronicle/code-analysis/arcchr0012/) | Info / Warning under On | Immediate appends beside protected decisions or under On. | | [ARCCHR0013](/arc/backend/csharp/chronicle/code-analysis/arcchr0013/) | Info | A command or aggregate root names an event source with `[EventSourceType]` and a definition with that name exists in the compilation. | | [ARCCHR0014](/arc/backend/csharp/chronicle/code-analysis/arcchr0014/) | Warning | A reactor or reducer is filtered with `[FromEventSource(stream)]` to a stream the definition does not declare. | | [ARCCHR0015](/arc/backend/csharp/chronicle/code-analysis/arcchr0015/) | Warning | A model-bound command handler declares a nullable event, directly or in a supported awaitable/union branch. | | [ARCCHR0016](/arc/backend/csharp/chronicle/code-analysis/arcchr0016/) | Warning | `concurrency: true` on a stream-metadata attribute of a command whose `Handle()` returns `EventsWithConcurrencyScopes`. | | [ARCCHR0017](/arc/backend/csharp/chronicle/code-analysis/arcchr0017/) | Error | An `[EventStreamId]` template on a command names an unusable property or has malformed braces. | | [ARCCHR0018](/arc/backend/csharp/chronicle/code-analysis/arcchr0018/) | Warning | An `[EventStreamId]` template on a reactor, where it is used literally. | | [ARCCHR0019](/arc/backend/csharp/chronicle/code-analysis/arcchr0019/) | Warning | A conventional query resembling a read-model fold; discovery is unchanged. | | [ARCCHR0020](/arc/backend/csharp/chronicle/code-analysis/arcchr0020/) | Warning | A literal wrapper route names a stream its known definition does not declare. | | [ARCCHR0021](/arc/backend/csharp/chronicle/code-analysis/arcchr0021/) | Info | Legacy wrapper strings name a declared stream but drop its definition. | | [ARCCHR0022](/arc/backend/csharp/chronicle/code-analysis/arcchr0022/) | Warning | A command-key aggregate accompanies returned or appended events targeting a generated parameter. | | [ARCCHR0023](/arc/backend/csharp/chronicle/code-analysis/arcchr0023/) | Warning / Info | Distinguishing read-model keys with several or no matching command properties; repeated lookup keys. | | [ARCCHR0024](/arc/backend/csharp/chronicle/code-analysis/arcchr0024/) | Warning | Statically refused read shapes in Measure or On. | | [ARCCHR0025](/arc/backend/csharp/chronicle/code-analysis/arcchr0025/) | Warning / Info | Read-model or explicit Chronicle service dependencies in keyless event-producing commands. | | [ARCCHR0026](/arc/backend/csharp/chronicle/code-analysis/arcchr0026/) | Info / Warning under On | MongoDB collection parameters containing recognizable Chronicle-backed models. | | [ARCCHR0027](/arc/backend/csharp/chronicle/code-analysis/arcchr0027/) | Warning | A visible `OnRefused` callback returns a statically invalid refusal production. | | [ARCCHR0028](/arc/backend/csharp/chronicle/code-analysis/arcchr0028/) | Warning | Raw Chronicle reads and explicit read bypasses under On. | | [ARCCHR0029](/arc/backend/csharp/chronicle/code-analysis/arcchr0029/) | Error | Conflicting protected and advisory declarations. | | [ARCCHR0031](/arc/backend/csharp/chronicle/code-analysis/arcchr0031/) | Error | Invalid explicit LookUpBy property or non-model parameter. | | [ARCCHR0032](/arc/backend/csharp/chronicle/code-analysis/arcchr0032/) | Info | Inert or redundant declarations. | | [ARCCHR0033](/arc/backend/csharp/chronicle/code-analysis/arcchr0033/) | Warning | Aggregate and protected plain read on the same key under On. | | [ARCCHR0034](/arc/backend/csharp/chronicle/code-analysis/arcchr0034/) | Info | Method-level advisory does not cover read parameters. | | [ARCCHR0035](/arc/backend/csharp/chronicle/code-analysis/arcchr0035/) | Warning | Early aggregate commit under On. | | [ARCCHR0036](/arc/backend/csharp/chronicle/code-analysis/arcchr0036/) | Warning | Exact scope coverage of a protected read is not proven, including validator-first stream reads. | | [ARCCHR0037](/arc/backend/csharp/chronicle/code-analysis/arcchr0037/) | Error | Per-stream model parameter does not match the command route, or uses LookUpBy. | | [ARCCHR0038](/arc/backend/csharp/chronicle/code-analysis/arcchr0038/) | Info | Per-stream model guard widens through another read or declared concurrency. | A command that has `[EventStreamId(value)]` and also implements `ICanProvideEventStreamId` is reported by Chronicle’s own analyzer as CHR0027, not by an Arc rule. `[EventStreamId("")]` and `[EventStreamId(null)]` declare no id, so ARCCHR0017 stays silent for them. ## Quick fixes | Rule | Fix | | --- | --- | | [ARCCHR0008](/arc/backend/csharp/chronicle/code-analysis/arcchr0008/) | Rewrite to the fully qualified Chronicle Key attribute. | | [ARCCHR0010](/arc/backend/csharp/chronicle/code-analysis/arcchr0010/#quick-fix) | Compiler-checked local replacement with `EventSourceId` for supported direct tuple syntax. | | [ARCCHR0013](/arc/backend/csharp/chronicle/code-analysis/arcchr0013/) | Replace matching source/stream attributes with a typed event source definition. | | [ARCCHR0031](/arc/backend/csharp/chronicle/code-analysis/arcchr0031/) | Use nameof for a nearby command lookup property. | | [ARCCHR0021](/arc/backend/csharp/chronicle/code-analysis/arcchr0021/#quick-fix) | Replace legacy wrapper route strings with the matching definition and stream. | | [ARCCHR0015](/arc/backend/csharp/chronicle/code-analysis/arcchr0015/#quick-fix) | Compiler-checked replacement of a nullable event with an explicit event-or-validation result and rejection placeholders. | | [ARCCHR0016](/arc/backend/csharp/chronicle/code-analysis/arcchr0016/#quick-fix) | Remove `concurrency: true` from the attribute. | | [ARCCHR0019](/arc/backend/csharp/chronicle/code-analysis/arcchr0019/#quick-fix) | Preview whole-type explicit query selection, retaining intended endpoints. | The other rules have no automatic fix. A fix can change behavior: in particular ARCCHR0010 changes the persistence target when the Guid was an ordinary response. ## Installation Install the optional Arc integration package: ```bash dotnet add package Cratis.Arc.Chronicle ``` It brings in `Cratis.Arc.Chronicle.CodeAnalysis`, which supplies these analyzers and code fixes. Complete the [integration setup](/arc/backend/csharp/chronicle/cratis-package/) to connect Arc’s command pipeline to Chronicle. Pure `Cratis.Chronicle` provides event sourcing without Arc; referencing it alone does not install Arc’s integration diagnostics. If a diagnostic is missing, check that your application’s resolved dependencies include `Cratis.Arc.Chronicle.CodeAnalysis`. # ARCCHR0001: Incorrect aggregate root event handler signature > Aggregate root event handlers must follow one of the allowed On method signatures to be discovered. ## Rule Event handler methods (typically named `On`) on aggregate roots must accept an event parameter and optionally an `EventContext` parameter, and return `void` or `Task`. ## Severity Error ## Allowed signatures These are signature fragments. The analyzer first filters candidates to methods with one or two parameters and a known event as the first parameter. A three-parameter method is outside that filter, so it does not receive ARCCHR0001 even though it is not a supported aggregate event handler. Verify discovery/replay in specs rather than relying on silence. ```csharp void On(TEvent @event) Task On(TEvent @event) void On(TEvent @event, EventContext context) Task On(TEvent @event, EventContext context) ``` ## Example ### Violation ```csharp public class UserAggregateRoot : AggregateRoot { // ARCCHR0001: Invalid return type public string OnUserCreated(UserCreated @event) { return "not allowed"; } // ARCCHR0001: Task is not allowed public Task OnUserNameChanged(UserNameChanged @event) { return Task.FromResult(42); } // Not reported by ARCCHR0001: excluded before candidate validation public void OnUserUpdated(UserUpdated @event, EventContext context, string extra) { } } ``` ### Fix ```csharp public class UserAggregateRoot : AggregateRoot { public void OnUserCreated(UserCreated @event) { // Handle event. } public Task OnUserNameChanged(UserNameChanged @event) { return Task.CompletedTask; } public void OnUserUpdated(UserUpdated @event, EventContext context) { // Handle event with context. } } ``` ## Why This Rule Exists Arc’s aggregate integration discovers event handlers from their event parameter and supported signature; names such as `On` are conventions. Standardized signatures ensure: * Handlers are discovered consistently. * Event processing remains predictable. * Asynchronous handlers integrate cleanly with the runtime. ## Related Rules * None # ARCCHR0002: Ambiguous command identity > A command has several properties that can be its event source id, and nothing says which one to use. ## Rule **Warning**. A `[Command]` has two or more public instance properties that can supply the command’s event source id, does not implement `ICanProvideEventSourceId`, and does not return events that carry their own event source id. A property is a candidate when it is any of these: | Candidate | Example | | --- | --- | | A type that is or derives from `EventSourceId` | `Isbn Isbn` where `Isbn : EventSourceId` | | A type with an implicit conversion to `EventSourceId` | A `ConceptAs` with an implicit conversion | | A property marked with Chronicle’s `[Key]` | `[Key] Guid OrderId` | | A positional parameter marked with `[Key]` and the property it creates | Counted once | Without a decision, Arc uses the first matching property. That choice also decides which entity the command appends to and which key a read model parameter reads by default, so a silent wrong choice changes the rule. ## Resolve the diagnostic | Situation | Fix | | --- | --- | | Only one of the properties is the command’s subject | Make the others `ConceptAs` values, not `EventSourceId` | | The subject is computed or fixed | Implement `ICanProvideEventSourceId` and return the id | | The command appends to both entities | Return `EventForEventSourceId` for each event. Each event then carries its own id and the rule does not apply | The analyzer also recognizes a single wrapper or a sequence of wrappers in the success branch of `Result`, including `Task>`. No `ICanProvideEventSourceId` is needed for those return values. An error branch carrying a wrapper does not give an untargeted success event a destination. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; public record Isbn(string Value) : EventSourceId(Value); public record MemberId(string Value) : EventSourceId(Value); [EventType] public record BookBorrowed(MemberId Member); [Command] public record BorrowBook(Isbn Isbn, MemberId Member) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => Isbn; public BookBorrowed Handle() => new(Member); } ``` `[Key]` on one of the properties does not resolve the ambiguity. It marks a candidate. See [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/) for the rules Arc applies at run time. ## Boundaries This analyzer’s candidate rule includes implicit conversions, which run-time discovery does not generally do. A conversion-only `ConceptAs` is not a safe run-time identity declaration. Use `EventSourceId` ancestry or a selected key. Neither a clean analyzer result nor a recognized return type proves that dependencies resolved before `Handle()` (an aggregate, a read model parameter) use the id you intend. Under read protection `On`, name the key of a read model parameter with `[LookUpBy]`. No code fix is offered. # ARCCHR0003: Reactor must not reach the default event log > A reactor appends to the default event log directly instead of returning the events from its handler method. ## Rule A reactor produces side-effect events by **returning** them from its handler method — Chronicle appends what a handler returns. Reaching the default event log yourself performs the same write, but outside the side-effect pipeline. This rule fires on every way of reaching it: | Shape | What it looks like | | --- | --- | | Injected event log | `MyReactor(IEventLog eventLog) : IReactor` | | Event log through the store | `eventStore.EventLog.Append(...)` | | Default log named explicitly | `eventStore.GetEventSequence(EventSequenceId.Log).Append(...)` | | Enlisted in a unit of work | `eventStore.EventLog.Transactional.Append(...)` | | Reached with a null-conditional | `eventStore?.EventLog.Append(...)` | Completing a stream through the event log (`eventStore.EventLog.CompleteStream(...)`) is reported too. The message then tells you to return [`CompleteStream`](/arc/backend/csharp/chronicle/commands/completing-streams/) from the handler instead. Only an injected event store chain is inspected for this; an `IEventLog` constructor parameter is reported without knowing how it is used. `Transactional` hands back the same sequence enlisted in a unit of work, so the write is identical — the chain is one member longer. Every `Append*` overload counts, `AppendMany` included, and `?.` anywhere in the chain changes nothing. ## Severity Warning ## Example ### Violation ```csharp [Reactor] public class IncomingInvitationReactor(IEventStore eventStore) : IReactor { [OnceOnly] public Task On(UserInvited @event, EventContext context) => // ARCCHR0003: appends to the sequence the return type already targets eventStore.EventLog.Append(context.EventSourceId, new InvitationIssued(@event.Email)); } ``` ### Fix ```csharp [Reactor] public class IncomingInvitationReactor : IReactor { [OnceOnly] public InvitationIssued On(UserInvited @event) => new(@event.Email); } ``` Return a single event, an `IEnumerable`, or `EventForEventSourceId` wrappers when the events belong to a different event source. To trigger work in another slice, inject `ICommandPipeline` and [execute a command](/arc/backend/csharp/chronicle/reactors/command-side-effects/). ## When It Does Not Fire **Injecting `IEventStore` is not itself a violation.** The rule reports the *append*, not the dependency — a reactor that injects the store to read from it, to reach `IReadModels`, or to route to a different sequence is left alone. Routing to another sequence is the case that matters most: ```csharp [Reactor] public class AcceptanceOutbox(IEventStore eventStore) : IReactor { [OnceOnly] public Task On(InvitationAccepted @event, EventContext context) => eventStore.GetEventSequence(EventSequenceId.Outbox) .Append(context.EventSourceId, @event); } ``` A returned side-effect event is always appended to the **default event log** — neither a bare event nor an `EventForEventSourceId` carries an `EventSequenceId` — so the outbox is not expressible as a return value and the store is the only way to reach it. The rule stays silent whenever `GetEventSequence` names anything other than the default log, including a sequence resolved at runtime. **Another event store is the same kind of exception.** A returned event goes to *this* reactor’s own store, in *this* namespace. A store the reactor obtains at runtime targets a different one, so the rule’s advice would send the event somewhere else entirely: ```csharp [Reactor] public class AuthorReplicator(IChronicleClient client) : IReactor { [OnceOnly] public async Task On(AuthorRegistered @event, EventContext context) { var other = await client.GetEventStore("Reporting", "tenant-x"); await other.EventLog.Append(context.EventSourceId, @event); } } ``` The rule reports an append only through the event store the reactor **holds** — a constructor parameter, a field, or a property. Anything else is treated as a store the reactor went and found. ## What The Rule Cannot See The analyzer reads the call in front of it. It does not follow values, so an append the rule would otherwise report goes unreported whenever the thing being appended to arrives from somewhere else in the method: | Shape | Why it is missed | | --- | --- | | `var log = eventStore.EventLog; log.Append(...)` | The sequence is held in a local. | | `var store = eventStore; store.EventLog.Append(...)` | The store is held in a local, and a local can hold another store as readily as this one. | | `GetEventSequence(sequenceId)` where `sequenceId` is a parameter or a field | The sequence is only known at run time; it may or may not be the default log. | | `GetEventSequence(flag ? EventSequenceId.Log : EventSequenceId.Outbox)` | Same — the analyzer cannot pick a branch. | | An append in a base class that does not itself implement `IReactor` | The rule decides from the type the append sits in, and that type is not a reactor. | | An append in a helper type the reactor delegates to | Same — the helper is not a reactor. | None of this is a suppression mechanism to reach for. It is the honest boundary of a syntactic rule: **it catches the shape you write by accident, not the one you write to get around it.** Every one of these still costs you `ReactorScenario.Produced` at spec time, which is where the absence really bites. ## Existing Code May Now Warn ARCCHR0003 previously matched only an injected `IEventLog`. It now also matches an append through an injected `IEventStore`, so a reactor with that shape starts warning on the first build after you upgrade — and if you build with warnings as errors, that is a build break rather than a warning. **The migration is to return the event.** Change the handler from `Task` to the event type and drop the store: ```csharp // Before — warns, and ReactorScenario.Produced sees nothing public Task On(UserInvited @event, EventContext context) => eventStore.EventLog.Append(context.EventSourceId, new InvitationIssued(@event.Email)); // After — Chronicle appends the returned event, and ShouldHaveProduced() works public InvitationIssued On(UserInvited @event) => new(@event.Email); ``` If the append targets a different event source, return `EventForEventSourceId(id, @event)` — or an `IEnumerable` mixing bare events and wrappers. If it targets a different sequence or a different store, the rule does not fire in the first place. ## Ordered manual appends Keep the warning for ordinary default-log appends. If a reactor must inspect an append result before deciding the next append, wait for observer completion, or pass append options that returned events cannot express, a **narrow, justified suppression** is appropriate. Return events instead when you do not need that control. Do not turn the rule off project-wide to accommodate one workflow. This handler-body excerpt assumes an `IEventStore eventStore` held by the reactor, an `EventContext context`, application event records `InvitationIssued` and `InvitationTracked` (the latter accepts an `EventSequenceNumber`), and an application exception `InvitationAppendFailed`. It uses the first append’s result to construct the second event: ```csharp // The second append needs the first append's sequence number; returned events cannot supply it here. #pragma warning disable ARCCHR0003 var issued = await eventStore.EventLog.Append(context.EventSourceId, new InvitationIssued(@event.Email)); if (!issued.IsSuccess) { throw new InvitationAppendFailed(); } var tracked = await eventStore.EventLog.Append(context.EventSourceId, new InvitationTracked(issued.SequenceNumber)); #pragma warning restore ARCCHR0003 if (!tracked.IsSuccess) { throw new InvitationAppendFailed(); } ``` Keep `#pragma warning restore ARCCHR0003` immediately after the ordered appends, and explain why returning events cannot represent this particular workflow. Handle every append failure; an earlier successful append is not undone if a later append fails. The same narrow-scope rule applies if your reason is a completion wait or append options rather than an append result. Manual appends bypass the **returned-side-effect observation**: `ReactorScenario.Produced` does not contain them, and `ShouldHaveProduced()` cannot assert them. Add separate tests for the actual appends, their outcomes, and their order. Suppression changes only the diagnostic: it gives no replay protection or transactional guarantee. Decide replay behavior explicitly and make the workflow safe to retry. ## Why This Rule Exists The two shapes write the same event to the same sequence, so the rule’s observation boundary applies to both. Returning an event lets Chronicle observe it as a reactor side effect; manual appends need their own tests and explicit replay and failure handling. The cost shows up later, in the testing surface. `ReactorScenario.Produced` is *the side effects the reactor returned from its handler methods* — a handler that returns bare `Task` and appends through an injected store produces nothing by that definition, so `ShouldHaveProduced()` throws and the sanctioned assertion surface is simply unavailable. Nothing at authoring time tells you that you have left the contract; this rule does. ## Related Rules * [ARCCHR0005](/arc/backend/csharp/chronicle/code-analysis/arcchr0005/) — Chronicle is used but not wired up # ARCCHR0004: [EventType] repeats the type name as its id > An [EventType] attribute passes the type's own name, or an empty string, as its id — neither changes what the event type resolves to. ## Rule Chronicle resolves an event type’s identifier from `[EventType]`’s `id` argument when one is given, and falls back to the CLR type name otherwise. Passing an id equal to the type’s own name — or an empty string — produces exactly the identifier the attribute would have used anyway. This rule fires on both: ```csharp [EventType("AuthorRegistered")] // ARCCHR0004: this is already the default public record AuthorRegistered(string Name); [EventType("")] // ARCCHR0004: same as a bare [EventType] public record AuthorRegistered(string Name); ``` The comparison is ordinal, matching how Chronicle compares event type identifiers, and it only considers a **constant** id — one the compiler can evaluate at the call site (a literal, `nameof(...)`, or a reference to a `const` field). A non-constant id cannot be checked against the type name here, so it is left alone rather than guessed at. ## Severity Warning ## Example ### Violation ```csharp using Cratis.Chronicle.Events; // ARCCHR0004: 'AuthorRegistered' passes its own name as the id [EventType("AuthorRegistered")] public record AuthorRegistered(string Name); ``` ### Fix ```csharp using Cratis.Chronicle.Events; [EventType] public record AuthorRegistered(string Name); ``` ## When It Does Not Fire **An id that differs from the type name is left alone — it is the documented way to rename an event record.** Chronicle resolves the stored event type by its id, not by the CLR type name, so pinning the original id is what lets you rename the record in code while every event already appended under the old name keeps resolving: ```csharp using Cratis.Chronicle.Events; // The record used to be called FeatureUITemplateSet. Renaming it in code is safe // because the id keeps every already-stored event resolving under its original name. [EventType("FeatureUITemplateSet")] public record FeatureScreenTemplateSet(string TemplateId); ``` **Removing the id here is not a cleanup — it orphans every stored event.** Once the id is gone, `[EventType]` falls back to the type name, `FeatureScreenTemplateSet`, and every event Chronicle already has on disk under `FeatureUITemplateSet` stops resolving to this type. Only remove an id once you have confirmed no stored event still depends on it — and even then, prefer leaving it as its own explicit statement of the type’s history over relying on the rule’s silence to justify the removal. The rule also stays silent on a few narrower cases: | Shape | Why it is left alone | | --- | --- | | An id that differs from the type name, in any casing | A genuinely different identifier — the comparison is ordinal, so `[EventType("authorregistered")]` on `AuthorRegistered` is not redundant | | A non-constant id (a variable, a method call, a property) | Cannot be evaluated at compile time, so it cannot be compared against the type name | | The `generation` argument, alone or alongside an id | Never part of this rule — evolving an event’s generation is unrelated to its id | ## Why This Rule Exists An id equal to the type name is pure noise: it changes nothing about how the event resolves, but it reads as if it might matter, which is exactly what leads someone to “simplify” a genuinely load-bearing id on a different type later. Reporting only the provably redundant case — not every explicit id — keeps that noise out without ever risking a false positive against the rename pattern the id argument exists to support. ## Related Rules * [ARCCHR0006](/arc/backend/csharp/chronicle/code-analysis/arcchr0006/) — Reactor handler invoking ICommandPipeline.Execute does not say what replay should do # ARCCHR0005: Chronicle is used but not wired up > A project uses Chronicle features but sets Arc up without WithChronicle() or AddCratis(). ## Rule Running Arc **without** Chronicle is a valid, supported setup — `AddCratisArc` on its own backs commands and queries with MongoDB or EF Core and never needs an event store. So a missing `WithChronicle()` is only a problem when the project actually **uses** Chronicle. This rule fires when a project calls `AddCratisArc` (without `WithChronicle()` or `AddCratis()`) yet does one of the following in the same project: * declares an **aggregate root**; * declares a **reactor** or a **reducer**; * declares a **projection** — fluent (`IProjectionFor<>`) or model-bound (a read model with `[FromEvent]`, `[SetFrom]`, `[SetValue]`, or any other `Cratis.Chronicle.Projections` attribute); * declares an **`[EventType]`** event; * injects a Chronicle service such as **`IEventLog`** or **`IEventStore`** (appending events, returning events from a command handler, reading the event store). Detection is by namespace: implementing any interface, or applying any attribute (on the type or its properties), from the `Cratis.Chronicle` namespace counts as Chronicle usage — so new Chronicle features are covered automatically. In any of those cases the event store is required, so a command, query, reactor, or reducer that touches Chronicle fails to resolve at runtime. The rule only reports when the setup call and the Chronicle usage live in the **same project**. When Arc is set up in a separate host project, it stays silent — and when the project genuinely doesn’t use Chronicle, it never fires. ## Severity Warning ## Example ### Violation ```csharp using Cratis.Chronicle.Events; var builder = WebApplication.CreateBuilder(args); // ARCCHR0005: Chronicle artifacts exist, but Chronicle is never wired up builder.AddCratisArc(); var app = builder.Build(); app.UseCratisArc(); app.Run(); [EventType] public record AuthorRegistered(string Name); ``` This is a host fragment with a deliberate ARCCHR0005 diagnostic. Supply the ASP.NET/Arc extension imports and packages; the type declaration follows all top-level statements. ### Fix For this ASP.NET Core host, reference the **`Cratis` package** (`dotnet add package Cratis`). `Cratis.Arc.Chronicle` alone supplies the generic-host integration, not all the ASP.NET Core extensions used here. Add the Chronicle client with the explicitly qualified ASP.NET Core `WithChronicle()` overload; this avoids ambiguity when both `Cratis.Arc` and `Microsoft.AspNetCore.Builder` are imported: ```csharp builder.AddCratisArc(configureBuilder: arc => Microsoft.AspNetCore.Builder.ArcBuilderExtensions.WithChronicle(arc)); var app = builder.Build(); app.UseCratisArc(); app.UseCratisChronicle(); app.Run(); ``` Or use the all-in-one `AddCratis()`, which wires Arc, the Chronicle client, and identity together. Its header-based identity adapter requires [trusted authenticated ingress](/arc/backend/csharp/chronicle/cratis-package/#basic-setup): ```csharp builder.AddCratis(); var app = builder.Build(); app.UseCratis(); app.Run(); ``` ## Why This Rule Exists `AddCratisArc` deliberately supports [running Arc without an event store](/arc/arc-without-event-sourcing/), backed by MongoDB or EF Core. That flexibility means the framework cannot assume Chronicle is wanted — so forgetting `WithChronicle()` is a silent mistake that only surfaces the first time an event is appended or read. This rule catches it at compile time, before the application runs. ## Related Rules * None # ARCCHR0006: Reactor handler invoking ICommandPipeline.Execute does not say what replay should do > A reactor handler calls ICommandPipeline.Execute without declaring what should happen to that call when the observer replays. ## Rule A reactor handler that calls `ICommandPipeline.Execute` produces a side effect — running a command, which can itself append events, call external systems, or both. When the observer replays (redaction, revision, an observer rewind), the same handler runs again for the same event unless something says otherwise, so `Execute` runs again and the side effect repeats. This rule fires whenever a handler Chronicle would actually dispatch to reaches `ICommandPipeline.Execute` — directly, or through any number of private helpers on the same reactor — without a decision for what replay should do: neither `[OnceOnly]` nor a `[Replay]` handler for the same event type. A public or protected `[Replay]` handler inherited from a base reactor (including a constructed generic base) counts as that decision; a private base method does not, because Chronicle cannot dispatch to it on the derived reactor. ## Severity Warning ## Example ### Violation ```csharp using Cratis.Chronicle.Reactors; using Cratis.Arc.Commands; public class OrderProcessing(ICommandPipeline commandPipeline) : IReactor { // ARCCHR0006: replay re-executes ChargeCard for the same order public Task On(OrderPlaced @event) => commandPipeline.Execute(new ChargeCard(@event.OrderId)); } ``` ### Fix Mark the handler `[OnceOnly]` when replay should simply not repeat the charge: ```csharp using Cratis.Chronicle.Reactors; using Cratis.Arc.Commands; public class OrderProcessing(ICommandPipeline commandPipeline) : IReactor { [OnceOnly] public Task On(OrderPlaced @event) => commandPipeline.Execute(new ChargeCard(@event.OrderId)); } ``` Or declare a `[Replay]` handler when replay should do something else — including nothing, stated explicitly: ```csharp using Cratis.Chronicle.Reactors; using Cratis.Arc.Commands; public class OrderProcessing(ICommandPipeline commandPipeline) : IReactor { public Task On(OrderPlaced @event) => commandPipeline.Execute(new ChargeCard(@event.OrderId)); [Replay] public Task OnReplay(OrderPlaced @event) => Task.CompletedTask; } ``` ## Choosing between \[OnceOnly] and \[Replay] The two attributes answer different questions, and they are not interchangeable. **`[OnceOnly]` is scoped to the whole replay of an event source, not to one occurrence of the event.** While a source is being replayed, every occurrence of that handler’s event type on that source is skipped — not only whichever occurrence already ran live. That is the right shape for a side effect that must never repeat because it already happened during the live pass: send the confirmation email once, charge the card once. It is the wrong shape whenever the handler has to run again during a replay — an event type that legitimately recurs on the same source and needs the same (or different) handling every time a rebuild replays it. `[OnceOnly]` does not distinguish that case from the one it is designed for: the handler is skipped for every occurrence, silently, with nothing in the observer’s state to say so. **`[Replay]` declares a separate handler for the same event type that takes over for the entire replay**, leaving the live handler untouched. This is the right shape whenever replay should do something rather than nothing — including calling `Execute` again deliberately, running an adjusted version of the side effect, or doing nothing at all. An empty `[Replay]` body is a legitimate, common answer: it is the explicit version of what `[OnceOnly]` does implicitly, and this rule treats declaring one the same way it treats `[OnceOnly]` — as proof the decision was made, not as a decision this rule second-guesses. ## When It Does Not Fire The rule only considers the handlers Chronicle’s dispatch would actually select for an event type, and follows a call through private helpers on the same reactor back to the handler that reaches it: | Shape | Why it is left alone | | --- | --- | | The handler, or the whole reactor class, carries `[OnceOnly]` | The replay decision has already been made | | A sibling handler for the same event type carries `[Replay]` | The decision has been made, even if the live handler is unmarked | | A private method shares an event type with a public handler | Chronicle would only ever dispatch to the public one; the private method’s call is unreachable in practice | | `Execute` is called on something other than `ICommandPipeline` | Not the call this rule is about | | The type does not implement `IReactor` | Not a reactor | ## What The Rule Cannot See The rule reads calls within one reactor’s own declared members. A few shapes reach `Execute` in ways it cannot follow: | Shape | Why it is missed | | --- | --- | | `Execute` called through a helper on a different type the reactor delegates to | The call graph this rule walks is limited to methods declared on the reactor itself | | `Execute` called inside a handler inherited from a base type | The rule records calls in the reactor’s own method bodies; inherited `[Replay]` handlers are recognized as replay decisions for those calls, but an inherited call to `Execute` is not traced back to the derived reactor | | An event type recognized only through a base type or interface, rather than as the parameter’s exact declared type | Dispatch-candidate detection looks for `[EventType]` on the parameter’s own type | | A reactor that returns a command for `ICommandPipeline` to execute, rather than calling `Execute` itself | There is no `Execute` invocation for the rule to find — nothing here indicates a side effect needing a replay decision | None of this is a suppression mechanism to reach for. The call graph is confined to the reactor’s own methods: it catches the call you write directly or through a private helper, not one reached through an inherited body or another type entirely. ## When The Rule Is Wrong A handler can call `Execute` in a shape neither attribute fits cleanly — the command is itself idempotent by construction, for instance, so re-running it during replay is safe by design rather than by a `[OnceOnly]`/`[Replay]` decision. **Suppress the diagnostic — do not mark the handler `[OnceOnly]` just to silence the warning:** ```csharp [SuppressMessage("Arc.Chronicle", "ARCCHR0006", Justification = "ChargeCard is idempotent per order and safe to re-execute on replay")] public Task On(OrderPlaced @event) => commandPipeline.Execute(new ChargeCard(@event.OrderId)); ``` Marking the handler `[OnceOnly]` to make the warning go away is the one response this rule tries hardest to avoid inviting: it changes replay behavior — skipping the handler for every occurrence, silently — for a case where that was never the actual answer. A suppression says “considered, and this is fine as written” without changing what the handler does. ## Why This Rule Exists The two mechanisms look interchangeable from a distance — both are attributes, both are about replay — and choosing the wrong one fails exactly the way that is hardest to notice: nothing throws, no spec run against the live path catches it, and the difference only shows up the next time the observer actually replays, as either a duplicated side effect or a silently skipped one. Naming the decision at the call site — mark it, declare a `[Replay]` handler, or suppress with a reason — moves that failure from replay time to build time. ## Related Rules * [ARCCHR0004](/arc/backend/csharp/chronicle/code-analysis/arcchr0004/) — \[EventType] repeats the type name as its id # ARCCHR0007: Command handler injects IEventLog > Express command writes through returned events or transactional appends. ## Rule **Warning**. A command injects `IEventLog` in Handle or Provide. Return events instead of bypassing Arc’s append pipeline. To close a stream, return `CompleteStream`. Under On, immediate appends are refused, even inside `[Unprotected("why")]` method bodies: that declaration permits advisory reads, not immediate writes. Use `IEventLog.Transactional` when returning events is not possible. ## Boundaries This signature rule does not prove whether an injected log is actually used or whether its appends are transactional. Suppress with a justification for a deliberate transactional-only dependency. No code fix is offered. # ARCCHR0008: Command key marked with the data annotations Key attribute > A command in a Chronicle application marks its key with System.ComponentModel.DataAnnotations.KeyAttribute, which Chronicle does not resolve keys from. ## Rule Two attributes are spelled `[Key]`, and which one a command needs depends on whether the application has Chronicle: | Attribute | Resolved by | | --- | --- | | `Cratis.Chronicle.Keys.KeyAttribute` | Chronicle, as the command’s event source id | | `System.ComponentModel.DataAnnotations.KeyAttribute` | Arc — but only in an application with **no** Chronicle | This rule fires when a `[Command]` type marks a property with the data annotations attribute in a project that references Chronicle. It stays silent when the property also carries Chronicle’s attribute, on anything that is not a command, and in a project without Chronicle — where the data annotations attribute is the right one. ## Severity Warning ## Example ### Violation These command fragments assume an application `Customer` read model and `CustomerRenamed` event; the example demonstrates key selection, not projection registration. ```csharp using System.ComponentModel.DataAnnotations; using Cratis.Arc.Commands.ModelBound; [Command] public record RenameCustomer([property: Key] Guid CustomerId, string NewName) { // ARCCHR0008: Chronicle does not resolve keys from this attribute public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName); } ``` ### Fix ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Keys; [Command] public record RenameCustomer([property: Key] Guid CustomerId, string NewName) { public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName); } ``` A command whose key is not one property implements `ICanProvideEventSourceId` instead. ## Quick Fix **Use the Chronicle Key attribute** rewrites the attribute for you. It writes the attribute out in full rather than adding a using: the file already has one for the data annotations namespace, and with both in scope a bare `[Key]` is ambiguous (`CS0104`). Remove the data annotations using yourself if nothing else in the file needs it, and the name shortens to `[Key]`. ## Why This Rule Exists The mistake is invisible without it. The code compiles, and it reads exactly like a command that declares its key — but, unless another recognized identity source is present, Chronicle generates a fresh event source id instead of using that property. An existing-state lookup then usually finds nothing. A required dependency yields [`ReadModelDoesNotExistForCommand`](/arc/backend/csharp/chronicle/read-models/failures/#readmodeldoesnotexistforcommand) — “The command targets an entity that does not exist” — which points at the data rather than at the attribute, on a request whose entity exists perfectly well. The two attributes are also easy to reach for by accident. The data annotations one is what an Entity Framework Core read model marks its primary key with, so it is already imported in projects that use both, and an editor completing `[Key]` offers whichever namespace is in scope. ## Related Rules * [ARCCHR0005](/arc/backend/csharp/chronicle/code-analysis/arcchr0005/) — Chronicle is used but not wired up ## See also * [Read models from other providers](/arc/backend/csharp/chronicle/read-models/other-providers/#declaring-the-key-without-chronicle) — declaring a command’s key with and without Chronicle # ARCCHR0009: Command property reads as a secret and should be marked [NotAudited] > A command carries a property whose name reads like a secret, and its value will be written to the causation of every event the command appends. ## Rule A command’s property values are recorded on the [causation chain](/arc/backend/csharp/chronicle/commands/causation/) of every event it appends. This rule fires when a `[Command]` type has a public property whose name contains a word that reads as a secret — `Password`, `Token`, `ApiKey`, `Credential`, `Pin`, `Cvv` and the like — and neither the property, its positional parameter, nor the command is marked `[NotAudited]` or `[PII]`. It matches whole words, not fragments: `PasswordPolicyId` is reported, `Passenger` and `Subtotal` are not. It also judges the type, because a name is weak evidence on its own — `AccessTokenExpiresAt` holds a `DateTimeOffset` and is a timestamp, not a secret, so a property whose type is a date, a duration, a number, a bool, a `Guid` or an enum is never reported however it is named. A concept is judged by the value it wraps, since that is what gets recorded. It stays silent on anything that is not a command, and in a project without Chronicle, where nothing is written to a causation chain at all. ## Severity Warning ## Example ### Violation These are attribute-focused fragments using application event and service types. The command-only `ResetCredentials` declaration below omits `Handle()` intentionally; supply it in a complete command. ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record ChangePassword(Guid UserId, string Password) { // ARCCHR0009: 'Password' is written to the causation of every event this command appends public PasswordChanged Handle(IPasswordHasher hasher) => new(hasher.Hash(Password)); } ``` ### Fix ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; [Command] public record ChangePassword(Guid UserId, [property: NotAudited] string Password) { public PasswordChanged Handle(IPasswordHasher hasher) => new(hasher.Hash(Password)); } ``` Marking the positional parameter — `[NotAudited] string Password` — works the same way. A command that exists only to carry secrets is marked once, on the type: ```csharp [Command] [NotAudited] public record ResetCredentials(Guid UserId, string Password, string RecoveryCode); ``` If the value is personal data, `[PII]` also excludes it from causation. Event encryption is separate: annotate the serialized event property/type or a shared concept. A command-only annotation does not encrypt a value copied into a new event. When the secret has its own concept, mark the concept once and every command that takes one is covered: ```csharp [NotAudited] public record ProviderApiKey(string Value) : ConceptAs(Value); ``` ## Quick Fix None. Use `[NotAudited]` for audit exclusion, `[PII]` for personal-data semantics, or [a suppression](#when-the-rule-is-wrong) for an intentional safe audit value. Secrets and personal data can overlap. `[NotAudited]` does not encrypt event payloads; `[PII]` controls encryption only where the persisted type/property carries that metadata. Do not store plaintext credentials in events. ## Why This Rule Exists A name-based guess is normally a poor basis for a diagnostic. It earns its place here because of what is at the other end of it: the causation is written into the event log, the event log is immutable, and a secret recorded there cannot be taken back out by changing code. Fixing it after the fact means redacting events. The cost of a false positive is one attribute; the cost of a miss is permanent. The mistake is also easy to make silently. Adding a property to a command is an ordinary edit, nothing about it says “this is now in the audit trail forever,” and the value only appears somewhere a person would notice — the Workbench, a replay, an export — long after the commit that introduced it. ## When the Rule Is Wrong A property can read as a secret and hold something you do want recorded. **Suppress the diagnostic — do not mark it `[NotAudited]`:** ```csharp [SuppressMessage("Arc.Chronicle", "ARCCHR0009", Justification = "The token's scope names, not the token")] public string TokenScopes { get; init; } ``` `[NotAudited]` would silence the warning by withholding the value, which is a behavior change dressed up as a suppression — the audit trail quietly loses a value you wanted in it. A suppression says “recorded on purpose” and keeps it. ## What It Does Not Catch Runtime causation exclusion checks top-level command values; it does not recursively redact nested objects serialized to JSON. Exclude the containing value when it carries nested secrets. The rule reads names, so it only sees secrets whose names say so. A property called `Value` or `Payload` holding an API key is invisible to it. Treat a clean build as “nothing obvious was missed,” not as “no secrets are recorded” — the decision about what belongs in a permanent audit record is still yours to make when you add the property. ## Related Rules * [ARCCHR0008](/arc/backend/csharp/chronicle/code-analysis/arcchr0008/) — Command key marked with the data annotations Key attribute ## See also * [Causation](/arc/backend/csharp/chronicle/commands/causation/) — what a command records, and how to keep a value out of it * [Compliance](/arc/backend/csharp/chronicle/compliance/pii/) — marking personal data # ARCCHR0010: Raw Guid response does not set the event source id > A heuristic checks whether a keyless command's ordinary Guid response was intended to identify its event source. ## Rule A raw `Guid` in a command response tuple is an ordinary response value. It is not Chronicle event-source metadata. This rule reports a keyless `[Command]` whose `Handle()` method returns both: * a raw `Guid`; and * a statically identifiable untargeted `[EventType]` event (including supported typed event collections). This rule belongs to the optional Arc–Chronicle analyzer package. It does not restrict ordinary standalone Arc command responses. Chronicle resolves a keyless command’s fallback event source id before `Handle()` runs. If `Handle()` later returns `(Guid, event)`, the caller receives that `Guid`, but the event is appended under the earlier fallback id. Return `EventSourceId` or, preferably, a domain identity derived from it when the response is meant to identify the new event source. The rule stays silent when runtime-recognized command properties or `ICanProvideEventSourceId` supply an identity, or the tuple includes explicit event-source identity metadata. `EventForEventSourceId` targets its own event; a sibling bare event can still trigger the rule. An unrelated confirmation Guid is also valid on a **keyless** command using a generated fallback stream. This is a signature heuristic, not proof of a defect or a runtime restriction. Check intent before changing routing. ## Severity Warning ## Example ### Violation ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] public record SubmitExpense(decimal Amount) { // ARCCHR0010: this Guid is only the response; it does not select the event source public (Guid, ExpenseSubmitted) Handle() { var expenseId = Guid.NewGuid(); return (expenseId, new ExpenseSubmitted(Amount)); } } [EventType] public record ExpenseSubmitted(decimal Amount); ``` ### Fix with a domain identity ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; public record ExpenseId(Guid Value) : EventSourceId(Value) { public static ExpenseId New() => new(Guid.NewGuid()); } [Command] public record SubmitExpense(decimal Amount) { public (ExpenseId, ExpenseSubmitted) Handle() { var expenseId = ExpenseId.New(); return (expenseId, new ExpenseSubmitted(Amount)); } } [EventType] public record ExpenseSubmitted(decimal Amount); ``` The returned `ExpenseId`, the appended event’s `EventSourceId`, and the projected read-model key now identify the same expense. ## Quick Fix **Use `EventSourceId` for the response identity** changes exactly one raw Guid element in supported direct tuple return syntax. Eligible handlers are declared on the command itself, non-virtual/non-override/non-abstract, and return a direct tuple or an async `Task`/`ValueTask` of a tuple. The current project must compile before and after the edit. There is no Fix All. No fix is offered for aliases, inherited handlers, unsupported factory/Result/OneOf returns, multiple Guid elements, or a change that produces compilation errors. Analyzer coverage is broader than fix coverage. The fix does not rewrite arbitrary bodies or dependent projects, and compilation does not prove domain intent. Prefer a domain identity such as `ExpenseId : EventSourceId` when appropriate; the tool cannot infer its name. **This edit changes persistence routing**, not just serialization or style. ## Intentional ordinary Guid response Do not invent a separate input identity solely to satisfy the analyzer. If fallback routing and an unrelated receipt are intentional, keep runtime behavior and suppress narrowly. This command fragment uses an application `ExpenseSubmitted` event: ```csharp [Command] public record SubmitExpense(decimal Amount) { #pragma warning disable ARCCHR0010 // Receipt id is unrelated to the generated event source. public (Guid ReceiptId, ExpenseSubmitted Event) Handle() => (Guid.NewGuid(), new ExpenseSubmitted(Amount)); #pragma warning restore ARCCHR0010 } ``` For inherited handlers the diagnostic is on the command declaration, so place the narrow suppression there. Keep a regression spec asserting the response and event-source identities intentionally differ. ## Analysis boundaries The current implementation examines public instance handlers, including inherited ones; known `Task`, `ValueTask`, `Result<,>`, and `OneOf` branches; and reference-type event arrays/typed collections. It uses runtime identity eligibility separately from ARCCHR0002’s broader implicit-conversion convention. It does not infer erased `object`/`IEnumerable` contents or arbitrary factory bodies. Custom lookalike wrappers and arbitrary `IOneOf` implementations are not treated as known unions. A clean result is not proof that every runtime event has the intended target. ## Why This Rule Exists Both values look like the identity of the created entity, and the command still succeeds. The mismatch only becomes visible when the caller uses the response to query the new entity or execute a follow-up command: Chronicle persisted the event under another id, so the lookup finds nothing. This shape appeared in an earlier starter template and in the generic tuple documentation. It compiles because `(Guid, event)` is a valid response-plus-side-effect tuple; it is a defect only when the ordinary Guid was intended to identify the created stream. ## Related Rules * [ARCCHR0002](/arc/backend/csharp/chronicle/code-analysis/#rules-overview) — ambiguous command event source id * [ARCCHR0008](/arc/backend/csharp/chronicle/code-analysis/arcchr0008/) — command key marked with the data annotations Key attribute ## See also * [Returning EventSourceId from a command](/arc/backend/csharp/chronicle/commands/returning-event-source-id/) * [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/) * [Command response value handlers](/arc/backend/csharp/commands/response-value-handlers/) # ARCCHR0011: Plain read model in an Off command is unprotected > Off state dependencies are hints, not guarded decisions. ## Rule **Info**, under Off (including projects with no declared mode). An event-producing command or its validator depends on Chronicle state without an explicit advisory declaration. Plain reads under Off do not enroll a guard. ## Resolve the diagnostic Enable `ReadProtection(On)` and take the model as a parameter. Prefer a `CommandValidator` constructor for state-dependent rules: under On its read shares the command’s protected unit. Under Off the same validator read is only a hint. For intentional advisory context use `[Unprotected("why")]`. ## Boundaries Measure and On are excluded, as are explicit decision-read parameters and advisory declarations. Source-visible state dependencies and raw model reads are recognized; external services and arbitrary helper dataflow are not proven. No code fix is offered. # ARCCHR0012: Immediate append bypasses protected decision > Stage events for the owning command's guarded commit. ## Rule **Info** for an explicit protected decision; **Warning** under On. A command immediately appends through `IEventLog`. Immediate writes cannot be rolled back after a decision conflict, and On refuses them even in Unprotected method bodies. ## Resolve the diagnostic Return events from Handle or use `IEventLog.Transactional`. For a stream decision, return `StreamDecision.Append`. A method-level advisory allowance does not permit immediate writes. ## Boundaries Transactional appends and ordinary Off commands without protected decisions are excluded. The analyzer checks recognizable calls in command bodies, not arbitrary aliases or helper dataflow. No code fix is offered. # ARCCHR0013: Use the event source definition > Replace matching legacy source and stream strings with a typed definition. ## Rule **Info**. A legacy event source type string uniquely matches a definition in the compilation. If a stream is named, that definition must declare it too. ## Resolve the diagnostic Use `[EventSource]` or `[EventSource("Stream")]`. The code fix combines the legacy source and stream attributes and keeps other routing attributes. Review the definition’s concurrency dimensions when migrating. ## Boundaries Unmatched or ambiguous definitions and undeclared streams are not reported. The fix is offered only when the source and any stream declaration can be changed on the same type; inherited stream policies require a deliberate migration. # ARCCHR0014: Observed event stream is not declared > A reactor or reducer is filtered to a stream its event source definition does not declare, so it would never see an event. ## Rule **Warning**. A reactor or reducer carries `[FromEventSource("stream")]`, and the definition `TSource` does not declare a stream named `stream` with `[EventStream("stream")]`. A stream that the definition does not declare cannot match, so no event would reach the observer. The diagnostic is reported on the attribute. ## Resolve the diagnostic Declare the stream on the definition, or observe a stream it declares. ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSources; using Cratis.Chronicle.Reactors; [EventSource("Account")] [EventStream("Transactions")] public class AccountEventSource : IEventSource; [EventType] public record TransactionRecorded(decimal Amount); [FromEventSource("Transactions")] public class TransactionObserver : IReactor { public Task Recorded(TransactionRecorded @event) => Task.CompletedTask; } ``` Misspelling `"Transactions"` in the attribute, or removing the `[EventStream]` attribute from the definition, reports this rule. ## Boundaries Only the definition’s own attributes are read, so a definition in a referenced assembly is checked the same way as one in your project. A stream argument that is not a constant string, and a definition that is not resolved yet, are left alone. No code fix is offered. # ARCCHR0015: Command handler must not return a nullable event > Reject an event-producing command explicitly instead of declaring an optional event result. ## Rule A model-bound `[Command]` whose public instance `Handle()` declares a nullable `[EventType]` event receives this warning. It applies to a direct `TEvent?`, `Task`, `ValueTask`, and nullable event branches inside `Result<…>` or `OneOf<…>`, including those unions inside an awaitable. Return `Result` and reject with `ValidationResult.Error(...)` when the command cannot record its event. You can also reject earlier in a validator or `Provide()`. A nullable `ICommandOperation` result is a different, supported [optional operation contract](/arc/backend/csharp/commands/operations/). This rule does not report operations, non-event response types, or a nullable error branch beside a non-nullable event. ## Severity Warning ## Example These handler fragments assume an application event named `ExpenseSubmitted` with a decimal `Amount` property. See [returning events](/arc/backend/csharp/chronicle/commands/events/) for integration setup. ### Violation ```csharp // ARCCHR0015: null would report success without recording an event. public ExpenseSubmitted? Handle() => Amount > 0 ? new ExpenseSubmitted(Amount) : null; ``` ### Explicit rejection ```csharp using Cratis.Arc.Validation; using Cratis.Monads; public Result Handle() => Amount > 0 ? new ExpenseSubmitted(Amount) : ValidationResult.Error("Amount must be positive", [nameof(Amount)]); ``` The event branch is non-nullable. The rejection branch tells the caller why no event was recorded. For an input-only rule such as this one, a command validator is also appropriate. ## Quick fix **Return an event or an explicit validation rejection** replaces a supported nullable event response with `Result`. A literal `null` returned directly or in a conditional return branch becomes `ValidationResult.Error("TODO: explain why")`. Replace the placeholder with a meaningful explanation and, when relevant, the affected member names before using the command. The fix supports direct event results, async `Task`/`ValueTask` handlers, and two-branch `Result`/`OneOf` responses containing exactly the nullable event and `ValidationResult`. It preserves the awaitable and leaves unrelated null values and nested functions alone. Both the original and edited project must compile. There is no Fix All. No fix is offered for inherited, virtual, override or abstract handlers, additional union branches, or an edit that fails compilation. Non-async factories such as `Task.FromResult`, casts, nullable local variables and callers depending on the old signature can require manual changes. Analyzer coverage is broader than fix coverage. The compiler check covers the current project, not downstream callers. ## Analysis boundaries The analyzer examines declared return types, not whether a handler actually returns null. A handler declared nullable is reported even if every path currently returns an event. It recognizes framework types by identity, follows inherited event metadata, and checks inherited public instance handlers. An inherited handler is reported on the command declaration rather than on its shared base method. Custom lookalike wrappers, erased `object` results, tuple elements, and collection contents are outside this rule. Nullability on the awaitable itself, such as `Task?`, is not a nullable event branch. ## Why this rule exists A direct or awaited null event result currently succeeds without appending anything because the command pipeline skips response processing for null. A null event branch inside `Result` or `OneOf` takes a different path: the pipeline unwraps the branch and attempts to inspect the null response’s type, producing a `NullReferenceException` failure. Neither outcome gives the caller a meaningful rejection reason. This warning makes the declared shape visible at build time; it does not change runtime behavior. ## See also * [Returning events](/arc/backend/csharp/chronicle/commands/events/) * [Validation](/arc/backend/csharp/commands/validation/) * [Optional command operations](/arc/backend/csharp/commands/operations/) # ARCCHR0016: Concurrency flag is ignored when the handler returns exact concurrency scopes > A stream-metadata attribute sets concurrency true on a command whose Handle method returns EventsWithConcurrencyScopes, so the flag has no effect. ## Rule `[EventSourceType]`, `[EventStreamType]` and `[EventStreamId]` take a `concurrency` flag that adds the attribute to the concurrency scope Arc derives for the command’s events. When `Handle()` returns `EventsWithConcurrencyScopes`, the returned scopes are used exactly as they are and nothing is derived from the attributes, so `concurrency: true` does nothing. The rule fires on a model-bound `[Command]` whose public instance `Handle()` returns `EventsWithConcurrencyScopes` directly, in a `Task<>` or `ValueTask<>`, inside a `Result<>`/`OneOf<>` branch, or as an element of a tuple. It reports the `concurrency` argument whether it is passed positionally or by name. `concurrency: false` and a missing argument are not reported. ## Severity Warning ## Example ### Violation ```csharp [Command] [EventStreamId("settlements", concurrency: true)] // ARCCHR0016 public record SettleFunds(EventSourceId AccountId) { public Task Handle(IStreamReads streams) => ...; } ``` ### Fix ```csharp [Command] [EventStreamId("settlements")] public record SettleFunds(EventSourceId AccountId) { public Task Handle(IStreamReads streams) => ...; } ``` ## Quick fix **Remove concurrency: true** deletes the argument and keeps the rest of the attribute. Fix All is supported. ## Event source definitions are not reported A definition (`[EventSource]` with concurrency dimensions) is a shared policy that other writers also follow, so the command cannot remove it. An exact scope, for example from `StreamDecision.Append`, intentionally replaces it for this append. The rule therefore only looks at the attributes on the command itself. ## See also * [Returning events: events with exact concurrency scopes](/arc/backend/csharp/chronicle/commands/events/#events-with-exact-concurrency-scopes) * [Stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/) * [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/) # ARCCHR0017: [EventStreamId] template is invalid > ARCCHR0017 reports an [EventStreamId] with {Property} placeholders that name a missing property, a non-string-convertible type or unbalanced braces. ## Rule On a command, `[EventStreamId("{ReportingScopeId}:{Period}")]` is a template. Arc resolves each `{Name}` from the command’s public instance properties. `{{` and `}}` write literal braces, and a value without placeholders is a constant stream id that this rule does not inspect. The rule reports, on the attribute value, each of: * a placeholder that is not a public instance property of the command (unknown, private, internal, static or an indexer); * a placeholder whose type does not convert to a string part: the supported types are `string`, primitives, `decimal`, `Guid`, enums, `DateOnly`, `DateTime`, `DateTimeOffset`, `TimeOnly`, `TimeSpan`, `ConceptAs` over one of these, and `EventSourceId` derived types, each optionally nullable; * malformed braces: an unclosed `{`, a stray `}`, or an empty `{}`. ## Severity Error ## Example ```csharp [Command] [EventStreamId("{Scope}:{Period}")] // ARCCHR0017: 'Period' is not a public instance property public record CloseBooks(string Scope) { ... } ``` Add a public `Period` property of a supported type, or write `{{Period}}` if you meant literal braces. ## Not resolved outside commands Only Arc commands resolve templates. A reactor reads the same attribute as a literal constant, so a template there is reported by [ARCCHR0018](/arc/backend/csharp/chronicle/code-analysis/arcchr0018/). ## See also * [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/) * [Stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/) # ARCCHR0018: Event stream id template is only resolved for commands > A reactor has an [EventStreamId] value with {Property} placeholders, but only Arc commands resolve them. ## Rule Chronicle reactors read `[EventStreamId]` as a literal constant. Only Arc commands resolve `{Property}` placeholders against their properties. A reactor class (a type implementing `IReactor`) whose `[EventStreamId]` value contains a placeholder therefore appends its side-effect events to a stream literally named, for example, `{Period}`. A constant value is not reported, nor is an escaped `{{`/`}}` pair. Types that are neither commands nor reactors are not inspected. This is a separate rule from [ARCCHR0017](/arc/backend/csharp/chronicle/code-analysis/arcchr0017/): ARCCHR0017 is an error about a template on a command that cannot be resolved, while this is a warning about a template that will not be resolved at all. ## Severity Warning ## Example ```csharp [EventStreamId("{Period}")] // ARCCHR0018: used literally by the reactor public class PeriodReactor : IReactor { ... } ``` Use a constant stream id, or route the events explicitly. ## See also * [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/) # ARCCHR0019 - Read-model fold may be exposed as a query > Preview explicit endpoint selection when a conventional query resembles a fold. ## When this warning appears A conventional `[ReadModel]` has a public/internal static `Create(event[, context])` returning its model or `Task` of the model, **without a self parameter**. The first parameter resembles an event: a class or interface other than a string or concept. This is a heuristic, not proof of event registration, and it does not change query discovery. Methods with a parameter of their own read-model type are never queries, nullable or not and in any position. Static self-reducer `Reduce(event, current[, EventContext])` methods therefore need no warning and no explicit-selection workaround. Adding `[Query]` to such a method is an [ARC0026](/arc/backend/csharp/code-analysis/arc0026/) error. In `RequireQueryAttribute = true` mode, unmarked helpers produce no query diagnostics, including this warning. Arc does not globally reserve fold or callback names. ## Choose the endpoint surface If the method is a helper, [opt the read model into explicit query selection](/arc/backend/csharp/queries/model-bound/static-methods/#choose-endpoints-explicitly-when-a-model-has-helpers). Add `[Query]` to intended queries, not to the fold. Preserve endpoint paths, HTTP methods, arguments, and authorization. If the method is an intentional query, keep it and suppress this heuristic with a justification. Adding an event attribute or context parameter does not exclude it from conventional discovery. ## Quick fix **Require \[Query] and mark existing queries (preview endpoints)** changes the whole read model, including its partial declarations. It opts the type into explicit selection and marks eligible existing methods other than recognized fold-like shapes. Preview the complete endpoint diff: return shapes cannot distinguish an intentional query from every other helper. Partial query methods receive one marker across their definition and implementation. The fix is not offered for already-invalid explicit query declarations, declarations in generated or otherwise uneditable documents, or a migration that introduces compiler errors. It has no unattended Fix All. The warning remains a creator heuristic, not a test of Chronicle backing eligibility. See [self-reducing read models](/arc/backend/csharp/chronicle/read-models/#self-reducing-read-models) for the supported reduction and command-injection shape. # ARCCHR0020: Wrapper event stream is not declared > A literal per-event route names a stream its known event source definition does not declare. ## Rule A literal route on `EventForEventSourceId` names a stream that its event source definition does not declare with `[EventStream]`. This is a routing typo, not a warning about choosing a route different from the command’s. The analyzer checks object-creation and `with` initializers. It resolves `EventSource = typeof(Definition)` directly, including definitions in referenced assemblies. For legacy `EventSourceType` strings, it looks for an unambiguously named definition in the current compilation. An unset source uses the enclosing `[Command]`’s `[EventSource]` definition. The stream must be named on the wrapper itself, not inherited from the command. A source-only legacy override does not trigger this warning. Outside a command, both the source and stream must be known. ## Severity Warning ## Example This example declares `Transactions`, but routes the returned event to `Transactons`: ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSources; [EventSource] [EventStream("Transactions")] public class AccountEventSource : IEventSource; public static class AccountEvents { public static EventForEventSourceId Route(EventSourceId id, object fact) => new(id, fact) { EventSource = typeof(AccountEventSource), EventStream = "Transactons" // ARCCHR0020 }; } ``` Use `EventStream = "Transactions"`, or declare the intended alternative stream on the definition. The same warning applies to `EventSourceType = "Account", EventStreamType = "Transactons"`. There is no automatic fix: the analyzer cannot decide whether you meant an existing stream or a new declaration. ## Boundaries and runtime behavior * Typed alternative declared streams are valid, even when they differ from the command route. * Computed strings, `route.Route(...)`, unknown source names, and unset `EventSourceType.Default` / `EventStreamType.All` values are not reported. Literal `"Default"` and `"All"` have the same sentinel behavior on those legacy properties. * Stream ids are free-form and are not checked against definitions. * The analyzer does not infer routing inherited from an unknown `with` receiver. * For definition-routed commands, a legacy wrapper route whose resolved source matches a registered definition but whose wrapper-named `EventStreamType` is undeclared throws `EventStreamDoesNotBelongToEventSource` before any returned events are enrolled or appended. This includes computed values and exact-scope responses. A stream-only override is checked against the command’s definition; a source-only override inherits the command’s stream string without this check. Unknown source names and commands without a definition keep their legacy behavior. * Framework dependency injection uses the validating handler constructors taking `IEventSources`. The obsolete legacy constructors remain available for manual construction but do not perform this registry-based validation. ## See also * [ARCCHR0021: Use event source definition routing](/arc/backend/csharp/chronicle/code-analysis/arcchr0021/) * [Per-event routing](/arc/backend/csharp/chronicle/commands/events/#per-event-routing) * [Route a command through an event source definition](/arc/backend/csharp/chronicle/commands/event-source-definitions/): declaring streams and migrating from wrapper strings. * [Chronicle’s Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/): why the stream a returned event lands on decides which events its concurrency check sees. # ARCCHR0021: Use event source definition routing > Legacy wrapper route strings match a declared definition stream but omit its definition and concurrency policy. ## Rule Legacy `EventSourceType` / `EventStreamType` strings on `EventForEventSourceId` spell out a declared event source stream. The route still works, but Arc appends by those strings rather than through the definition: `EventContext.EventSource` and the selected definition’s concurrency dimensions are lost. When the strings equal the enclosing command’s `[EventSource(stream)]` route, the message also says the override is redundant. A different declared route remains supported; this suggestion is about retaining the definition, not making every event follow the command. ## Severity Info ## Example This wrapper names the declared `Account.Transactions` stream using legacy strings: ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSources; [EventSource] [EventStream("Transactions")] public class AccountEventSource : IEventSource; public static class AccountEvents { public static EventForEventSourceId Route(EventSourceId id, object fact) => new(id, fact) { EventSourceType = "Account", EventStreamType = "Transactions" // ARCCHR0021 }; } ``` Replace the initializer’s routing assignments with: ```csharp EventSource = typeof(AccountEventSource), EventStream = "Transactions" ``` This initializer excerpt uses the definition above. It preserves the source and stream names while retaining the definition for the append. ## Quick fix **Use event source definition routing** replaces the legacy assignments with `EventSource = typeof(Definition)` and `EventStream = "stream"`. It keeps unrelated wrapper values, including stream id, subject, occurrence time, and tags. Fix All is supported. Review concurrency before applying the fix: the definition’s policy now participates in ordinary derived guards. Explicit command `concurrency: true` choices still win, and returned exact scopes still replace derived scopes. The fix does not remove command attributes or rewrite legacy routes automatically at runtime. ## Boundaries The analyzer recognizes literal strings, including routing-concept constructors around literals, on object-creation and `with` initializers. Legacy source-name lookup covers definitions in the same compilation only; ambiguous names, unknown names, undeclared streams, computed values, sentinels, and unknown inherited routing are left alone. If only a stream is set, the enclosing `[Command]`’s `[EventSource]` supplies the source. Typed routing and `IEventRoutes` / `route.Route(...)` need no migration diagnostic. ## See also * [ARCCHR0020: Wrapper event stream is not declared](/arc/backend/csharp/chronicle/code-analysis/arcchr0020/) * [Per-event routing](/arc/backend/csharp/chronicle/commands/events/#per-event-routing) * [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/) * [Route a command through an event source definition](/arc/backend/csharp/chronicle/commands/event-source-definitions/#wrapper-named-streams): the migration this rule suggests. * [Chronicle’s Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/): the boundary the definition’s concurrency dimensions give an append. # ARCCHR0022: Command-key aggregate on a generated append target > Align the aggregate's source with the generated target used for returned or appended events. ## Rule An automatically injected aggregate is rehydrated using the command’s event source id. A `[GeneratedValue]` Handle parameter does not change that id before dependencies resolve. Returning or appending events for the generated id therefore does not make the injected aggregate belong to that target. **Default severity: Warning.** The rule conservatively recognizes commands with an `EventSourceId` or `EventSourceId` property, a concrete aggregate parameter, and a directly returned `EventForEventSourceId` (or returned collection expression of wrappers) targeting a generated parameter. It also recognizes direct `IEventSequence.Append*` calls with that generated parameter as their source. It reports on the aggregate parameter. ## Fix Decide which source the command acts on. To act on the command’s existing source, return or append events for that source. To act on the generated source, remove the automatically injected aggregate and resolve the intended aggregate explicitly, or redesign creation so it does not need existing state from the wrong stream. There is no automatic code fix. ## Boundaries Commands with `Provide()` or `ICanProvideEventSourceId` are excluded: the analyzer cannot prove their aggregate resolution. Keyed service injection is excluded too. Indirect targets, wrapper aliases, helper calls, custom key-only properties, and unrecognized append surfaces can be missed. A quiet analyzer is not proof that the aggregate and append source agree. See [event source identity timing](/arc/backend/csharp/chronicle/resolving-event-source-id/) and [generated values](/arc/backend/csharp/commands/generated-values/) before changing the persistence target. # ARCCHR0023: Read-model lookup key is not proven > Signature-based hints about ambiguous, missing, or repeated read-model lookup keys. ## Rule A read-model parameter’s key is not necessarily the entity you intended to read. This analyzer compares a Chronicle-backed model’s `[Key]` or `Id`/`id` type with public command properties and the command’s event source key. For a distinguishing concept type different from the command key type: * Several matching properties produce a **Warning** naming the candidates. * No matching property produces **Info**. * Under On, exactly one matching property is selected and is not reported. * Under Off/Measure, a unique matching property produces **Info**: the read still uses the command key unless `[LookUpBy]` explicitly redirects it. Under On, ambiguity is reported even for primitive key types. Explicit `[LookUpBy]` parameters are excluded from this heuristic and validated by ARCCHR0031. A second parameter resolving to the same inferred key produces **Info**. Reading several views of one entity can be intentional; this advisory does not prove a bug. Raw primitives, `Guid`, `string`, `EventSourceId`, and the generic `EventSourceId` base are not distinguishing types. Derived domain identity types are distinguishing. ## Resolve the diagnostic Use `[LookUpBy(nameof(Command.Property))]` on the read-model parameter to select the intended entity in every mode, or use `Provide()` for a computed key. See [read-model injection](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/). Suppress a repeated-key advisory with a justification when both parameters intentionally represent the same entity. There is no automatic code fix. This analyzer does not change runtime resolution. On selects a unique matching property; Off and Measure retain the command key without an explicit `[LookUpBy]`. ## Boundaries The analyzer examines public instance `Handle()` and `Provide()` methods on recognizable event-producing model-bound commands. It recognizes model-bound projection metadata, projection/reducer targets declared in the compilation, and recognizable self-reductions. Custom event source providers, keyed service parameters, and `Handle()` dependencies on commands with `Provide()` are excluded because their lookup is not proven statically. Indirect reads, external fluent artifacts, runtime-only event registration, and erased event return types can be missed. A quiet analyzer does not prove lookup correctness. # ARCCHR0024: Read-model shape cannot be protected > Source-visible admission refusals under Measure and On. ## Rule **Warning**, in Measure or On. A protected read-model parameter has a shape Chronicle refuses: joins or children, an event-property key, an unsupported key, or a reducer with constructor dependencies or an observer event filter. Unfiltered dependency-free reducers are admitted. ## Resolve the diagnostic Read joined parts separately, append facts to the entity a model is keyed by, use a constraint for uniqueness, or remove reducer dependencies/filters. Use a string or Guid identity (including supported concepts). If the read is only context, mark the parameter or model `[Unprotected("why")]`. Method-level `[Unprotected]` does not opt out injected parameters. ## Boundaries The analyzer checks Handle/Provide dependencies and validator constructors. Off and explicitly advisory parameters are excluded. Source-visible fluent projection shapes are checked; runtime-only registrations, generation delivery (including Pinned), and definitions in other assemblies remain runtime-validated. A quiet analyzer is not proof of admission. No code fix is offered. # ARCCHR0025: Keyless command reads Chronicle state > Detect automatically injected read models looked up by a newly generated command identity. ## Rule A command without an event source key gets a newly generated id on every call. An automatically injected Chronicle read model is looked up by that id, not an existing entity’s identity. The analyzer reports a **Warning** on the read-model parameter, whether nullable or non-nullable. When the model does not exist, a nullable parameter receives null. A non-nullable parameter produces `ReadModelDoesNotExistForCommand`, a validation failure surfaced as HTTP 400. This is different from a declared key that cannot be composed, which produces an unspecified id and `UnableToResolveReadModelFromCommandContext`. An `IReadModels` or `IEventStore` parameter in the same keyless event-producing command produces **Info** instead: explicit reads might already use the right key, and their lookup is not proven by this analyzer. ## Resolve the diagnostic Declare the command’s event source key with a domain identity derived from `EventSourceId`, or mark the intended property with Chronicle’s `[Key]`. For a computed identity, implement `ICanProvideEventSourceId`. See [event source identity timing](/arc/backend/csharp/chronicle/resolving-event-source-id/). There is no automatic code fix. ## Boundaries The analyzer examines public instance `Handle()` and `Provide()` methods on recognizable event-producing model-bound commands. Inherited keys, positional `[Key]`, and custom identity providers count as key declarations; a null or uncomposable value cannot be proven from a signature. Keyed services and `Handle()` dependencies on commands with `Provide()` are excluded because they may be supplied explicitly. Model recognition includes model-bound projection metadata, source-declared projections and reducers, and recognizable self-reductions. External fluent artifacts, runtime-only event registration, erased event return types, and indirect reads can be missed. An explicit identity returned beside an event does not change earlier dependency lookup. # ARCCHR0026: Chronicle model read through a MongoDB collection > Identify collection dependencies whose model is recognizably backed by Chronicle. ## Rule An injected `IMongoCollection` reads the MongoDB sink directly. When `T` is recognizably backed by Chronicle, this analyzer reports **Info** outside On and **Warning** under On, where runtime parameter admission refuses the collection. A read through this collection is **not proven protected**. The analyzer does not inspect queries or prove that the result contributes to a command decision. Recognition includes Chronicle model-bound projection metadata on the model or its members, projection/reducer targets declared in the compilation, and recognizable self-reductions over attributed event types. An ordinary MongoDB model is not reported. ## Resolve the diagnostic For a command’s keyed state, inject the plain read model rather than its MongoDB collection. See [read-model injection](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) for the lookup and decision-read boundaries. This replacement alone does not prove that the command’s decision is protected. An invariant that must hold independently of a command’s read belongs in a Chronicle constraint. Outside On, intentional sink queries can retain the collection dependency and suppress this advisory with a justification. Under On, inject an admitted model instead; a suppression does not bypass runtime refusal. There is no automatic code fix. ## Boundaries Constructor and ordinary method parameters are examined, including primary constructors. Lambda and local-function parameters are not dependency injection sites and are ignored. The analyzer matches the full `MongoDB.Driver.IMongoCollection` identity, not just its short name. It does not recognize collection fields, local variables, indirect service lookups, external fluent projection/reducer artifacts, or runtime-only event registration. It does not change MongoDB access or Chronicle runtime behavior. # ARCCHR0027: Refusal branch must return registered events or acknowledge > Correct statically invalid OnRefused callback results before they fail a reactor delivery. ## Rule A `CommandInvocation.OnRefused` callback produces registered events, not further command work. **Default severity: Warning.** The analyzer reports statically visible invalid returns in expression or block lambdas, anonymous methods, and method groups whose source bodies are available. It checks conditional and switch-expression arms and literal collections. Commands, operations, stream completions, non-event objects, empty collections, null batch members, and envelopes containing invalid payloads are rejected. The runtime validates the complete refusal production before appending any branch events. One invalid member fails the delivery without appending the valid members of that branch. Earlier successful commands and previously appended events are not rolled back. ## Fix Return an event marked `[EventType]`, an `EventForEventSourceId` containing one event, or a stable nonempty array or list of those. Register the event types as usual. Return `null` or use `.Acknowledge(selector)` when handling the refusal should produce no events. An envelope must not contain another envelope or an event collection. Do not return a command from the refusal callback to try another operation. Choose the subsequent work outside the branch; a handled refusal stops the delivery’s remaining command invocations. There is no automatic code fix because the intended refusal outcome is a domain decision. ## Boundaries Only the actual Arc `CommandInvocation.OnRefused` method is checked; similarly named APIs are ignored. Method-group bodies outside the current compilation remain opaque, even when a project reference exposes their syntax. Collection-builder factory results are also opaque: an empty input does not prove that a custom builder returns an empty batch. The analyzer does not follow helper calls, erased object values, unknown generic elements, custom enumerators, or mutable collection dataflow. Nested functions are not callback returns unless supplied directly as the callback. A quiet analyzer is not proof of event registration, collection stability, or a valid runtime result. See [handling a command refusal](/arc/backend/csharp/chronicle/reactors/command-side-effects/#handle-a-command-refusal) for branch ordering, selectors, and failure behavior. # ARCCHR0028: Chronicle read is not proven protected > Raw reads and explicit allowances in On command bodies. ## Rule **Warning**, under On. A command or its validator calls a raw Chronicle read, `GetDetached`, or `AllowReads`. These calls are not proven to enroll the read through Arc’s command-aware reader. ## Resolve the diagnostic Take a read model as a Handle/Provide parameter or validator constructor parameter. For a computed key, use `IDecisionReads.Get` in Provide. For intentional advisory context, mark the method `[Unprotected("why")]`. An explicit `AllowReads` remains reported as a reviewable bypass. ## Boundaries Off, Measure and advisory method-body reads are excluded. The analyzer recognizes framework APIs by their declaring namespace and type; unrelated methods with the same names are not reported. It does not prove transitive helper calls or dynamic runtime behavior. No code fix is offered. # ARCCHR0029: Conflicting read protection > Choose a consistent protected or advisory command declaration. ## Rule **Error**, in every mode. A command combines `[ProtectedDecision]` with `[Unprotected]`, or explicitly declares On/Measure together with `[Unprotected]`. Inherited declarations participate in the same policy. ## Resolve the diagnostic Remove one conflicting declaration. A command-level `[Unprotected("why")]` may override an assembly rollout; an explicit command-level On/Measure may not coexist with that opt-out. `[ProtectedDecision]` plus On is compatible, not a conflict. There is no automatic code fix: the choice changes command policy. # ARCCHR0031: Invalid read-model lookup > LookUpBy must select an existing public instance command property on a read-model parameter. ## Rule **Error**, in every mode. `[LookUpBy]` names a missing public instance command property or annotates a provably non-model parameter. The attribute is placed on the injected parameter, not the property. On a `CommandValidator` constructor, the property belongs to `T`, not the validator. ## Resolve the diagnostic Use `[LookUpBy(nameof(Command.Property))]` with an existing command property. Arc converts its value to the wire key independently of the model’s CLR `Id` type. Concepts and their primitive values, `EventSourceId`-derived records, `Guid` and string keys do not have to share a CLR type. Null or empty keys are still refused at runtime. For a missing name, the code fix offers `nameof` only for a nearby spelling (edit distance at most two). No candidate means no fix, rather than changing lookup to an unrelated property. Unseen models can be backed by another project, so absence of local projection metadata alone is not proof that a parameter is not a model. # ARCCHR0032: Inert or redundant read-protection declaration > Remove only declarations that do not change the resolved policy. ## Rule **Info**. ReadProtection is declared on a sealed/static non-command type, Unprotected is declared on a provably non-model parameter, or Off repeats an already-Off assembly policy. ## Resolve the diagnostic Remove the inert declaration, or place it on the intended command, assembly, read-model parameter or model. Non-sealed bases are allowed to carry an inherited ReadProtection declaration even when the base itself has no Handle method. Off on a ProtectedDecision command in an On assembly is **not** redundant: it preserves the explicit protected-decision behavior instead of enforcing On. On on a ProtectedDecision command is **not** redundant: it enables read-model parameters and validator constructor reads and changes conflict diagnostics. Neither combination is reported as inert. ProtectedDecision also retains its explicit decision policy if an operational cap lowers On. No automatic removal is offered because changing rollout policy deserves review. # ARCCHR0033: Aggregate and read-model parameter share a source > Avoid combining aggregate and plain-model protection for the same source. ## Rule **Warning**, under On. An automatically injected aggregate and protected plain read-model parameter resolve to the same command key. Runtime refuses this combination; a model on a different key is allowed. ## Resolve the diagnostic Use the aggregate’s own state, or read the other entity explicitly. If the plain read is only context, mark it `[Unprotected("why")]`. ## Boundaries Only statically matching automatic keys are reported. Advisory reads, custom source providers, and Handle dependencies potentially supplied by Provide are excluded. Runtime checks remain authoritative for actual keys. No code fix is offered. # ARCCHR0034: Unprotected method does not opt out read-model parameters > Method advisory allowances start after parameter resolution. ## Rule **Info**, in Measure or On. A method is `[Unprotected("why")]` but has a non-advisory read-model parameter. The method attribute covers the body only; parameters are resolved before that body starts. ## Resolve the diagnostic If the parameter is intentionally advisory, annotate that parameter or the read-model type with `[Unprotected("why")]`. Otherwise keep the protected parameter and acknowledge the informational diagnostic with a justification. ## Boundaries Off and parameters already declared advisory are excluded. The analyzer only recognizes source-visible Chronicle-backed models. No code fix is offered. # ARCCHR0035: Protected aggregate is committed too early > Complete the protected unit only after the outer decision is finished. ## Rule **Warning**, under On. `aggregate.Commit()` is followed by a read or append in the same straight-line block, or the command is statically invoked through `ICommandPipeline.Execute` from another On command. Commit completes the unit; a nested command must leave completion to its owner. ## Resolve the diagnostic Read before committing and return Commit last in the outer command. Pass values to nested commands rather than committing there. ## Boundaries Top-level pipeline calls, Off/Measure commands and mutually exclusive branches are not reported. The analyzer does not infer arbitrary control flow, opaque helpers or runtime command dispatch. Runtime completion checks still apply. No code fix is offered. # ARCCHR0036: Exact scope coverage of a protected read is not proven > Exact scopes must cover the same-source reads they accompany. ## Rule **Warning**, in Measure or On. A command returns exact concurrency scopes (including a stream decision’s append result) beside an automatically resolved protected read on the command source. Coverage is not proven by that signature. ## Resolve the diagnostic Widen the exact scope to cover the read’s source, event types, dimensions and boundary, or remove the exact scope and let protected reads supply their guards. When an explicit scope intentionally covers every read, suppress with a justification after checking coverage. A per-stream model in a validator is read before `Provide()`. A stream decision acquired there can have a later boundary and fail with `ExplicitScopeDoesNotCoverReads`; that validator-first signature is reported too. Prefer the per-stream model alone for folded state, or the stream decision alone for raw facts. A direct `Provide(IStreamReads reads) => reads.ForCommand()` with a per-stream `Handle()` parameter on that same route is not reported for this combination. ## Boundaries Advisory reads, different statically known keys, custom source providers and Handle values potentially supplied by Provide are excluded. This heuristic does not prove predicates or boundaries; runtime scope validation is authoritative. No code fix is offered. ARCCHR0027 belongs to refusal-branch validation, not this rule. # ARCCHR0037: Read model stream is not on the command route > A per-stream model parameter requires the command's own source, stream and explicit stream id in every mode. Use this diagnostic when a per-stream model cannot select the command’s stream. Fix the route rather than falling back to a whole-source read. ## Rule **Error**, in `Off`, `Measure` and `On`. A `Handle()` or `Provide()` parameter, or a `CommandValidator` constructor parameter, takes a model declaring Chronicle’s `[EventStream(stream)]`, but the command’s source/stream differs or it declares no explicit stream id. `[LookUpBy]` on the parameter also reports this rule: a stream model belongs to the command’s own event source. ## Resolve the diagnostic | Problem | Resolution | | --- | --- | | No route, a different definition or stream, or `All` | Route the command with `[EventSource(stream)]` matching the model | | No stream id, or the default id | Add `[EventStreamId("{Month}")]`, another explicit id, or `ICanProvideEventStreamId` | | A lookup selects another source | Remove `[LookUpBy]`. Use the `IDecisionReads` stream overload in `Provide()` for another source or month | Matching source/stream string attributes are accepted by value; a typed definition keeps writers aligned. The [monthly-limit example](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/) shows the complete route and validator. Runtime mismatches produce `ReadModelStreamNotOnCommandRoute` before reading. Missing template values instead return `400` naming the property. ## Boundaries The rule checks declarations, not the value returned by a provider or the template’s input. Runtime route validation remains authoritative. Keyed services and handler values potentially supplied by `Provide()` are excluded. There is no code fix. # ARCCHR0038: Stream read guard widens > Combining a per-stream read with another same-source read or a broader declared concurrency policy can cause extra conflicts. Use this diagnostic when another month’s activity unexpectedly conflicts. The guard is safe, but it is not as narrow as the per-stream model alone. ## Rule **Info**, in `Measure` or `On`. A per-stream model parameter accompanies a source-visible whole-source read on the command source, a model of another stream, or effective declared `Concurrency` containing `EventSourceId` without both `EventStreamType` and `EventStreamId`. Validator constructor parameters participate too. ## Resolve the diagnostic | You need | Do this | | --- | --- | | Only this month’s folded state | Use only the per-stream model | | The whole account balance as well | Keep both reads and accept the whole-source guard | | Exact model event types within this stream | Do not declare an additional broader `Concurrency` policy | | Any event in this stream to conflict | Declare `EventSourceId \| EventStreamType \| EventStreamId` on the stream | Widening can add conflicts; it does not miss a change affecting a read. Under `On`, Chronicle retains each dimension the reads agree on: two months of the same stream type lose the id filter, while a whole-source read drops both stream filters. Measure reports read/read widening as `coarsened` with reason `stream_dims_dropped`. See [the monthly limit](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/#keep-the-route-exact) for the interaction with source-level fallback policies. ## Boundaries This is a signature/declaration diagnostic, not dataflow analysis. Advisory reads, different known lookup keys and `Off` are excluded. Arbitrary `IDecisionReads` calls in helpers and dynamic providers are not inferred. Runtime guard merging remains authoritative; absence of this diagnostic does not prove a narrow guard. There is no code fix. # Commands > How an Arc command becomes appended events — return shapes, event source identity, subject, and concurrency scoping. Chronicle extends the [Arc command pipeline](/arc/backend/csharp/commands/) with event sourcing behavior. The same [model-bound](/arc/backend/csharp/commands/model-bound/) command patterns work here — `[Command]` records, `Handle()` methods, `CommandValidator` — with Chronicle adding automatic event appending, event source identity resolution, and metadata-driven concurrency control. ## What Chronicle Adds A model-bound command handler in the Chronicle context can return events directly from `Handle()`, and Chronicle appends those events to the event log automatically. This keeps command handlers focused on decisions and domain logic rather than event log plumbing. Chronicle also resolves the event source identity, event stream metadata, and concurrency scope from the command record itself — either by convention or via explicit attributes and interfaces. This means the same record that defines your command’s shape also carries all the information Chronicle needs to append events correctly. ## What `Handle()` returns decides what happens Arc classifies the values returned by `Handle()`, and the Chronicle integration consumes events and their metadata. Other server-consumed values, including command operations and recognized validation results, are not client responses either. At most one ordinary unhandled value becomes the command’s **response**—so a tuple can declare a fact, additional work, and something to return without calling the event log yourself. Returned work is not response data Return events for durable facts and [command operations](/arc/backend/csharp/commands/operations/) for additional inline work. Neither becomes the client response. Operations run within their supported command boundary; their external effects are not atomically committed with Chronicle events. | `Handle()` returns | What Chronicle does | | --- | --- | | `void` / `Task` | No return-driven append; aggregates or explicit appends can still write events. | | An `[EventType]` event | Appended to the resolved event source’s log. | | `IEnumerable` of events | Each is appended, in order. | | `EventForEventSourceId` (or a collection of them) | Appended to the event source id carried *in the value*, overriding the resolved one — for writing to a different or several streams. | | `EventsWithConcurrencyScopes` | Appends ordered cross-source events with exact, independently labeled concurrency scopes in the command transaction. | | Tuple `(event, result)` with an ordinary unhandled result | The event is appended; the ordinary result becomes the caller’s response. | | Tuple containing events and command operations | Events enroll in the command transaction; operations execute before completion. At most one additional ordinary value may be the response. | | Tuple containing `EventSourceId` and an event, in either order | The `EventSourceId` sets the stream; the event is appended. See [Returning EventSourceId](/arc/backend/csharp/chronicle/commands/returning-event-source-id/). | | Tuple `(Guid, event)` on a keyless command | The `Guid` is only the response. Chronicle generates a different event source id; [ARCCHR0010](/arc/backend/csharp/chronicle/code-analysis/arcchr0010/) asks whether the Guid was intended as stream identity. | | Tuple `(event, EventTags)` | The tags attach to every returned event, not the response. See [Event tags](/arc/backend/csharp/chronicle/commands/event-tags/). | | Tuple `(event, Subject)` | The event is appended; the `Subject` is attached as [compliance metadata](/arc/backend/csharp/chronicle/commands/subject/), not returned. | | `Result` (e.g. `Result`) | A failure rejects pending transactional events; a success is unwrapped. Earlier immediate or explicit commits remain. | The rule of thumb: **return the fact that happened.** The event source id (see [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/)) decides which stream it lands on. ## Topics | Topic | Description | | --- | --- | | [Events](/arc/backend/csharp/chronicle/commands/events/) | Returning events from commands and how Chronicle appends them automatically, including stream metadata. | | [Event tags](/arc/backend/csharp/chronicle/commands/event-tags/) | Attaching structured named tags from command properties, handler results, and application-wide conventions. | | [Setting Subject](/arc/backend/csharp/chronicle/commands/subject/) | Supplying a compliance subject on the command or by returning it from `Handle()`. | | [Returning EventSourceId](/arc/backend/csharp/chronicle/commands/returning-event-source-id/) | Explicitly deciding the event source id by returning it from a command tuple. | | [Transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/) | Atomic enrollment, immediate appends, nested commands, and aggregate commit boundaries. | | [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/) | Declaring a concurrency scope on commands using metadata attributes and interfaces. | | [Event source definitions](/arc/backend/csharp/chronicle/commands/event-source-definitions/) | Routing a command’s events through a typed event source definition: streams, stream ids and the boundary it gives the append. | | [Event source definition scenarios](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/) | A stream per entity, per owner and period, and per process, with a stream decision and a completed stream. | | [How event source definition routing works](/arc/backend/csharp/chronicle/commands/event-source-definition-routing/) | How Arc resolves the definition, stream, stream id and concurrency scope. | | [Stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/) | Reading a routed stream and returning events guarded at the tail the decision used. | | [Completing streams](/arc/backend/csharp/chronicle/commands/completing-streams/) | Closing a stream from a command or a reactor. | # Causation > A command's values are recorded on the causation of every event it appends, permanently — and how to keep a value out of that record. Every event a command appends carries a causation chain saying how the work arrived: an HTTP request came in, a command ran, an aggregate root committed. Arc adds a link naming the command, and records **the values that command was asked to act on** alongside the name. Naming the command alone answers “which command produced this event.” Recording the values answers “which invocation” — two purchase orders raised by the same command are otherwise indistinguishable on the chain. Note The causation is written into the event log, and the event log is immutable. A value recorded there stays there for as long as the events do, is read by everything that ever replays them, and **cannot be taken back out by changing code**. Before adding a property to a command, decide whether its value belongs in a permanent audit record — and mark it [`[NotAudited]`](#keeping-a-value-out-of-the-record) if it does not. ## Who caused the event When you execute a command inside `ISystemExecution.As(principal)` without an HTTP request, Chronicle records that authenticated principal as the event’s `causedBy` identity. It uses the same claims as Chronicle’s HTTP provider: `sub` for the subject, `name` for the name, and `preferred_username` for the username; missing name and username claims fall back to `principal.Identity.Name`. An active HTTP request keeps Chronicle’s existing HTTP identity resolution; `ISystemExecution.As` does not replace its user. Outside HTTP, an explicitly selected non-System Chronicle identity still takes precedence. In a reactor, `ISystemExecution.As(principal)` replaces the System identity with the authenticated principal while preserving the triggering event’s `OnBehalfOf` chain. Without an override scope, an explicitly selected System identity stays System; clearing it resumes principal resolution. Without an authenticated principal, Chronicle’s identity stays unchanged. ## What gets recorded For a command like this: ```csharp [Command] public record RaisePurchaseOrder(PurchaseOrderId OrderId, SupplierId Supplier, decimal Amount) { public PurchaseOrderRaised Handle() => new(Supplier, Amount); } ``` the causation link carries: | Property | Value | | --- | --- | | `commandType` | `RaisePurchaseOrder` | | `commandTypeFullName` | `Acme.Purchasing.RaisePurchaseOrder` | | `eventSequenceId` | `event-log` | | `orderId` | `00000026-0000-0000-0000-0000000000b2` | | `supplier` | `ACME` | | `amount` | `1234.56` | Every readable public instance property is recorded, keyed by its camel-cased name. The values render as you would expect them to read: * A [concept](/fundamentals/csharp/concepts/) records the value it wraps, not the wrapper — `orderId` above is the id, not `{"Value":"…"}`. * Numbers and dates are written invariantly, dates round-trippably (`2026-02-26T11:03:00.0000000+00:00`), so a chain written in one locale reads the same in another. * A nested object or a collection is written as compact JSON. Exclusion is not recursive: `[PII]` or `[NotAudited]` on a nested member does not remove it from that JSON. Exclude the containing command property, its type, or the whole command when nested data is sensitive. * A property that is not set is left out entirely, rather than recorded as empty. ## Keeping a value out of the record Two markings keep a top-level command value off the chain. The snippets below are attribute-focused fragments using application types; commands shown without `Handle()` are not complete runnable commands. ### Personal data — `[PII]` Arc excludes annotated top-level command values from causation. This does **not** remove them from an event you construct: event encryption separately requires PII metadata on the serialized event property/type or a shared concept. The marking is honored wherever it is written — on the property, on the command, on the positional parameter, and **on the concept**, so a concept marked once carries the marking to every command that uses it: ```csharp [PII("The name of a person")] public record ClaimantName(string Value) : ConceptAs(Value); [Command] public record SubmitClaim(ClaimId ClaimId, ClaimantName Claimant); // claimant is never recorded ``` See [Compliance](/arc/backend/csharp/chronicle/compliance/pii/) for the full picture. ### Secrets — `[NotAudited]` Use `[NotAudited]` for a value that must not enter the audit record, such as a password or API key. `[PII]` also excludes annotated command values from causation; choose it based on privacy meaning, not a claim that secrets cannot be personal data. Payment-card information can be personal data. Neither annotation makes storing credentials in an event safe: ```csharp [Command] public record ChangePassword( UserId User, [property: NotAudited] string OldPassword, [property: NotAudited] string NewPassword) { public PasswordChanged Handle(IPasswordHasher hasher) => new(hasher.Hash(NewPassword)); } ``` Written on a positional parameter — `[NotAudited] string OldPassword` — it works the same way. Applied to a concept it travels with the value, the same way `[PII]` does — mark it once and every command that takes one is covered: ```csharp [NotAudited] public record ProviderApiKey(string Value) : ConceptAs(Value); ``` Applied to the command itself it excludes every property at once, which is the right answer when a command exists only to carry secrets, and stays right as properties are added to it later: ```csharp [Command] [NotAudited] public record ResetCredentials(UserId User, string Password, string RecoveryCode); ``` The command is still **named** on the chain either way. What is withheld is the values, never the fact that the command ran — an audit trail that hides which commands executed would not be an audit trail. ## The analyzer [ARCCHR0009](/arc/backend/csharp/chronicle/code-analysis/arcchr0009/) reports a command property whose name reads like a secret and which is not marked. It is a name-based guess, which is normally a poor basis for a diagnostic — it earns its place here because the cost of a false positive is one attribute and the cost of a miss is a password in the event log forever. It judges the type as well as the name, so a `DateTimeOffset` called `AccessTokenExpiresAt` is left alone — it is a timestamp, not a secret. When it is wrong the other way and a value that reads as a secret should be recorded, [suppress the diagnostic](/arc/backend/csharp/chronicle/code-analysis/arcchr0009/#when-the-rule-is-wrong) rather than marking it `[NotAudited]`, which would silence the warning by withholding a value you wanted. It will not catch a secret whose name does not say so. A property called `Value` holding an API key is invisible to it, and to any reviewer reading the model. The analyzer narrows the problem; it does not remove your judgment from it. ## Size A recorded value is cut short at 1024 characters, marked with `…` where it was cut. The causation travels on **every** event the command appends, so an unbounded value is written once per event — a value long enough to be truncated has stopped being an audit note and become a payload. ## Reading the chain In the [Chronicle Workbench](/chronicle/workbench/), an event’s **Context** tab shows its causation. Open `causation`, then an entry’s `properties`, to see what the command was asked to do. ## See also * [ARCCHR0009](/arc/backend/csharp/chronicle/code-analysis/arcchr0009/) — the analyzer for unmarked secrets * [Compliance](/arc/backend/csharp/chronicle/compliance/pii/) — marking personal data * [Events](/arc/backend/csharp/chronicle/commands/events/) — what a command returns # Completing streams > Return a stream completion from a command or reactor to prevent further events in that stream. Return `CompleteStream` when a stream must accept no more events. Arc completes it after the command’s returned events commit; your handler does not need to inject `IEventLog`. A completed stream accepts no more events, which is a different tool from the append check of Chronicle’s [Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/): completion closes the stream, the boundary rejects an append that missed an event. A [command routed through an event source definition](/arc/backend/csharp/chronicle/commands/event-source-definitions/) declares the stream it completes; [the per-process scenario](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/#a-stream-per-process) shows a complete example. This requires the `Cratis.Arc.Chronicle` integration and Chronicle kernel **19.39 or later** for event-source-scoped completion. An older kernel fails the completion; Arc never falls back to closing every source. Import `Cratis.Arc.Chronicle.Streams` for the completion value. ## Complete the command’s routed stream This command closes `completion / period` only for its event source `Id`. Another entity using the same stream type and id stays open. ```csharp using Cratis.Arc.Chronicle.Streams; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [EventType] public record PeriodCompleted; [Command] [EventStreamType("completion")] [EventStreamId("period")] public record CompletePeriod(EventSourceId Id) { public (PeriodCompleted, CompleteStream) Handle() => (new(), new()); } ``` `CompleteStream()` uses the command context’s resolved stream type and id, including metadata supplied by an event source definition. You can also return it alone when no final event is needed. It is a side effect, not the command’s response value: a tuple can still carry a separate response. Putting the completion before the event in the tuple does not change execution order. To select a different stream, supply either or both parts: ```csharp new CompleteStream(EventStreamType: "completion", EventStreamId: "another-period") ``` Each omitted part uses the corresponding command route value. Returning events with per-event routing overrides does not change these defaults; name the completion’s target explicitly when it differs from the command route. A `Default` or unset stream id cannot be completed for any stream type through Arc; commands receive a validation failure and reactors receive a side-effect failure. ## Understand the completion boundary A command with a declared key or a specified id from `ICanProvideEventSourceId` completes that source’s stream, narrowed further by its resolved event source type when one is declared. An event source id supplied in the handler’s response also scopes the completion. You do not need an owner-qualified stream-id template just to keep another entity’s stream open; templates remain useful for composing stream ids from command properties. A command **without a declared or response-supplied event source id**, including `EventSourceId.Unspecified`, retains the all-sources behavior: it closes that stream type and id across the entire event log. The id Arc generates internally for a keyless command does not scope its completion. Use that intentionally when closing a period for everyone. Per-event routing overrides do not change which event source owns the completion. Arc first commits the command’s events, then calls Chronicle to complete the stream. If the append or commit fails, Arc does not complete it. Nested commands wait for the outer command’s commit. Completing an already completed stream succeeds, making a repeated completion safe. Appending and completing are not atomic Arc performs append and completion as separate operations without an expected-tail guard. Another writer can append between the successful commit and completion. If completion fails or the process stops in that window, the final events remain committed and the stream can remain open. A failed command result does not mean those events were rolled back. Retrying only the completion is safe; blindly retrying a command that also emits events can duplicate them. Any later command appending to a completed stream receives a validation failure with Chronicle’s message naming the closed stream, rather than an internal-server error. Other completion refusals fail the command with a message identifying the target stream. ## Return a completion from a reactor A reactor can return `CompleteStream` directly or as `Task`. Null parts use the **observed event’s** stream type and id, not side-effect metadata declared on the reactor. Completion always includes the observed event’s event source id and event source type, even when you override the stream type or id. This complete reactor type uses the event declared above: ```csharp using Cratis.Arc.Chronicle.Streams; using Cratis.Chronicle.Reactors; public class CompleteObservedPeriod : IReactor { public CompleteStream PeriodCompleted(PeriodCompleted @event) => new(); } ``` Redelivery tolerates `AlreadyCompleted`. Other refusals become reactor side-effect failures. Chronicle does not support mixing this Arc completion with events in a tuple or collection. If a reaction must append final events and then complete, return a command that declares both instead. Replay also applies the completion; use Chronicle’s `[OnceOnly]` only when the handler should deliberately skip replay. ## Assert completion in a command scenario Import `Cratis.Arc.Chronicle.Testing.Commands`. This excerpt assumes a `CommandScenario` named `scenario` and an event source id named `id`: ```csharp await scenario.Execute(new CompletePeriod(id)); await scenario.ShouldHaveAppendedEvent(id); await scenario.ShouldHaveCompletedStream("completion", "period"); await scenario.ShouldHaveCompletedStream(new Cratis.Chronicle.EventSequences.ClosedStreamScope( EventSourceId: id, EventStreamType: "completion", EventStreamId: "period")); ``` `scenario.CompletedStreams` exposes successful completions as `(EventStreamType, EventStreamId)` pairs, including repeated completions that Chronicle reports as already completed. The pair assertion accepts either scoped or all-sources completions; the `ClosedStreamScope` overload asserts the exact owner and routing dimensions. Both work with protected decision scenarios. See [transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/) for event commit behavior and [reactor command side effects](/arc/backend/csharp/chronicle/reactors/command-side-effects/) for returning commands from reactors. # Concurrency > Build a concurrency scope from command metadata so appends participate in optimistic concurrency checks. Chronicle’s [concurrency control](/chronicle/events/concurrency/) rejects an append when its checked expectation no longer matches the protected history. Atomic append alone does not bind a decision to the state it read. A `ConcurrencyScope` defines the boundaries for that check — which stream type, stream id, and event source type form the concurrency boundary. On model-bound commands, you declare concurrency intent directly on the command record using attributes and interfaces. Chronicle then builds the `ConcurrencyScope` automatically when appending the events returned by `Handle()`. No manual scope construction is required. `concurrency: true` does not guard a decision The attributes on this page choose which dimensions bound the check Arc builds for the returned events. That check reads the tail after `Handle()` returns, so it cannot bind your decision to the state `Handle()` read. To make a stateful rule hold, use a constraint, a read model parameter under [read protection](/arc/backend/csharp/chronicle/consistency/getting-started/) `On`, a [stream decision](/arc/backend/csharp/chronicle/commands/stream-decisions/), an aggregate, or an explicit scope. [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) compares them. A [Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/) is the guarantee that an append fails if an event matching the decision’s query arrived after the position it read; [choosing a mechanism](/chronicle/dynamic-consistency-boundary/choosing/) compares the ways to get it. The attributes on this page are the string form of the boundary. An [event source definition](/arc/backend/csharp/chronicle/commands/event-source-definitions/) declares the same dimensions once, with a named stream, and is the typed alternative. A [guarded generated append target](/arc/backend/csharp/chronicle/commands/fresh-identifiers/) is an exception to automatic tail capture: Arc uses an immutable whole-source `BeforeFirst` scope. Existing events produce `ConcurrencyViolation` with `ReasonDetail = "GeneratedIdentifierCollision"`, regardless of routing metadata. That automatic path is otherwise the default. If the decision must remain bound to a revision it already read, return [`EventsWithConcurrencyScopes`](/arc/backend/csharp/chronicle/commands/events/#events-with-exact-concurrency-scopes) and supply that exact revision with the returned events. ## Concurrency Metadata Attributes Three attributes control concurrency scope declaration on a command. Each attribute serves a dual purpose: it tags the appended events with metadata *and*, when `concurrency: true` is set, contributes that metadata to the concurrency scope. ### `[EventStreamId]` Scopes concurrency to a specific event stream id within a stream type. Use this when independent streams within the same stream type should not interfere with each other. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] [EventStreamId("customer-profile", concurrency: true)] public record UpdateCustomerProfile(EventSourceId CustomerId, string DisplayName) { public CustomerDisplayNameChanged Handle() => new(DisplayName); } /// /// Records the customer's updated display name. /// [EventType] public record CustomerDisplayNameChanged(string DisplayName); ``` ### `[EventStreamType]` Scopes concurrency to a named stream type. Stream types group related streams — for example, separating `Onboarding` events from `Transactions` for the same customer. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] [EventStreamType("Transactions", concurrency: true)] public record ProcessPayment(EventSourceId AccountId, decimal Amount) { public PaymentProcessed Handle() => new(Amount); } /// /// Records the amount of a processed account payment. /// [EventType] public record PaymentProcessed(decimal Amount); ``` ### `[EventSourceType]` Scopes concurrency to a named event source type. This is the overarching concept the event source belongs to — for example `Customer` or `BankAccount`. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.Events; [Command] [EventSourceType("Customer", concurrency: true)] public record RegisterCustomer(EventSourceId CustomerId, string Email) { public CustomerRegistered Handle() => new(Email); } /// /// Records the email address supplied when a customer registered. /// [EventType] public record CustomerRegistered(string Email); ``` ## Combining Attributes You can combine multiple concurrency attributes to build a precise scope. Only the attributes with `concurrency: true` contribute to the scope the command declares — but the others still tag the appended events, and the fallback strategy narrows by whatever tags an append carries, so they are not concurrency-inert either. See [what a routing-only tag already does](#what-a-routing-only-tag-already-does) below. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.Events; [Command] [EventStreamId("customer-profile", concurrency: true)] [EventStreamType("Profile", concurrency: true)] [EventSourceType("Customer", concurrency: true)] public record UpdateCustomerProfile(EventSourceId CustomerId, string DisplayName, string Email) { public IEnumerable Handle() => [ new CustomerDisplayNameChanged(DisplayName), new CustomerEmailChanged(Email) ]; } /// /// Records the customer's updated display name. /// [EventType] public record CustomerDisplayNameChanged(string DisplayName); /// /// Records the customer's updated email address. /// [EventType] public record CustomerEmailChanged(string Email); ``` If no attribute has `concurrency: true`, the command contributes no scope of its own and the append is left to the concurrency strategy configured on the event sequence — by default the optimistic one, which resolves the expected tail for the event source being appended to, **narrowed by whatever routing metadata the command carries**. ## What a routing-only tag already does A metadata attribute declared *without* `concurrency: true` still narrows the concurrency check. Its value reaches the append regardless of the flag, and the fallback strategy resolves the expected tail with the same narrowing — so the flag governs whether the command **declares** a scope, while the tag governs what the check is **narrowed by**. Both are true at once. Three consequences follow, and the third is the surprising one: * **A routing-only tag silently narrows every concurrency check on that command.** `[EventStreamType("Attachments")]` with no flag restricts the expected tail to `Attachments` events, so a concurrent append to the same event source under a different stream type is invisible to the check. * **Declaring `concurrency: true` on *every* metadata attribute uses the same narrowing dimensions as declaring it on none, under the default optimistic strategy.** The declared scope passes the context values; the fallback passes the same values, with a sentinel standing in for anything absent — and a sentinel adds no filter. The capture timing differs: Arc resolves an explicit scope while processing the response; Chronicle resolves a missing scope during the unit of work’s commit-time `AppendMany()`. An intervening append can conflict with the earlier expectation but be included in the later fallback tail. * **Declaring it on a *subset* produces a strictly broader scope than declaring it on none.** The declared scope passes `null` for every dimension that did not opt in, while the fallback would have passed its real value. Declaring it on `[EventStreamType]` alone, on a command that also carries `[EventSourceType("X")]`, **drops** the `EventSourceType == "X"` filter and widens the check. Concurrency tags narrow the default check The practical reading: reach for `concurrency: true` to state intent and to pin which dimensions bound the check, not because its absence leaves the check unbounded. If you want a check bounded by the whole event source, do not tag the command at all. ## Dynamic Event Stream Id When the event stream id is determined at runtime rather than as a constant, implement `ICanProvideEventStreamId` and return the id from `GetEventStreamId()`. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.Events; [Command] [EventStreamType("Reporting", concurrency: true)] public record GenerateMonthlyReport(EventSourceId AccountId, string MonthKey) : ICanProvideEventStreamId { public EventStreamId GetEventStreamId() => MonthKey; public MonthlyReportGenerated Handle() => new(MonthKey); } /// /// Records the month covered by a generated account report. /// [EventType] public record MonthlyReportGenerated(string MonthKey); ``` > **Note**: If both a non-empty `[EventStreamId]` value and `ICanProvideEventStreamId` are present on the same command, Chronicle throws an `AmbiguousEventStreamId` exception. Choose one approach, or set the attribute value to `null` to defer to the interface. ## Template Event Stream Ids When a stream id combines command properties, use a template instead of implementing `ICanProvideEventStreamId`. Templates are resolved for **Arc commands only**. On a Chronicle reactor, the same `[EventStreamId]` value is used literally; it does not interpolate reactor properties. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] [EventStreamType("Reporting")] [EventStreamId("{ReportingScopeId}:{Period}")] public record GenerateReport(EventSourceId AccountId, string ReportingScopeId, DateOnly Period) { public ReportGenerated Handle() => new(Period); } [EventType] public record ReportGenerated(DateOnly Period); ``` `{Name}` selects a readable public instance property. Each part is formatted as follows: | Part type | Format | | --- | --- | | Concept | Its underlying value, using the rules below | | `string` | Unchanged | | Enum | Its name | | `DateOnly` | `yyyy-MM-dd` | | `DateTime`, `DateTimeOffset`, `TimeOnly` | Round-trip `O`, preserving seconds and fractional ticks | | `TimeSpan` | Invariant `c` | | Other `IFormattable` values, including numbers and `Guid` | Default format with invariant culture | | Other values | `ToString()` | `{{` and `}}` escape literal braces within a template. Unknown properties, null or blank parts, malformed braces, and a resolved id equal to `Default` or empty are refused. Arc resolves template parts on first route use after authorization and validation, so a validator can reject a missing part without a template exception. Constant values and `ICanProvideEventStreamId` keep their existing behavior. `EventStreamIdTemplate.Resolve("{ReportingScopeId}:{Period}", command)` resolves a template outside the pipeline. `EventStreamIdTemplate.ResolveFor(command)` resolves the command’s attribute or provider interface exactly as the pipeline does, and returns null when neither declares an id. Use the same function for a read or seed route instead of repeating the join. An attribute with a value and `ICanProvideEventStreamId` still raises `AmbiguousEventStreamId`. Owner-qualified templates are not needed to scope a [stream completion](/arc/backend/csharp/chronicle/commands/completing-streams/): when the command has an event source id, Arc closes only that source’s stream. Keep a composite id when those properties genuinely identify the stream. ## Event Source Id The event source id used when appending is resolved from the command by convention — not from the concurrency scope. See [Event Source Id Resolution](/arc/backend/csharp/chronicle/commands/events/#event-source-id-resolution) for the full resolution order, including `ICanProvideEventSourceId`. ## How the Scope Is Built When `Handle()` returns events, Chronicle inspects the command type for the three concurrency attributes. It reads the resolved metadata values from the command context and builds a `ConcurrencyScope` covering only the metadata where `concurrency: true` was set. Two properties of that scope decide whether the check actually happens, and both are resolved per append rather than once per command: * **It carries a checked expectation**, resolved by the event sequence’s strategy. An actual revision is checked and `BeforeFirst` protects empty matching history. `Unavailable` is not an empty-history expectation; incomplete expectations are skipped/reported rather than enforcing the invariant. Automatic first-append checking is opt-in through the client’s concurrency options. * **It is bound to the event source being appended to.** A command that appends across streams gets a scope per target, because an expected tail belongs to exactly one stream; applying one stream’s tail to another would be wrong for both. ## Carrying the revision used by the decision With an opted-in concurrency attribute, automatic optimistic concurrency resolves the expected tail while Arc handles the returned value. With no opted-in attribute, Arc leaves the scope unset and Chronicle resolves the fallback expectation during commit-time `AppendMany()`. Neither capture is necessarily the revision `Handle()` read. A concurrent append that lands after that read but before the applicable capture can become part of the new expected tail rather than cause a conflict. When that newer tail would invalidate the decision, capture the revision during the read and return it in `EventsWithConcurrencyScopes`. Arc passes it unchanged into the same command transaction as the ordered events. Interference after the read then produces a concurrency validation failure at commit, with no partial append. For a decision over stream facts, [`IStreamReads`](/arc/backend/csharp/chronicle/commands/stream-decisions/) captures the tail before reading the events and returns a `StreamDecision`. Its `Append(...)` routes the facts and builds the exact scope, including the empty-stream first-append check. This is opt-in. Returning ordinary events or `EventForEventSourceId` values keeps the automatic strategy and its existing behavior. For an empty read, explicitly convert `Unavailable` to `BeforeFirst` as shown in the [exact-scope example](/arc/backend/csharp/chronicle/commands/events/#events-with-exact-concurrency-scopes), and deploy a compatible server. A scope watching one event type cannot enforce a rule over changes outside that narrowing. ## Exact scopes replace automatic capture When `Handle()` returns `EventsWithConcurrencyScopes`, its supplied scopes replace automatic capture. For a guarded generated target, an equal whole-source `BeforeFirst` intent merges with the freshness guard; a differing explicit intent fails with `ConflictingConcurrencyScopes`. Remove `concurrency: true` from `[EventSourceType]`, `[EventStreamType]` and `[EventStreamId]` on that command, but keep their routing values. A later automatically captured tail would defeat the purpose of guarding the revision used by the decision. For `[EventSource(stream)]`, keep the concurrency dimensions on the shared definition. The exact returned scope intentionally takes precedence for this command without changing the policy for other commands using the same definition. Chronicle carries one scope per event source id per command, so two stream decisions for the same source cannot supply independent guards. # How event source definition routing works > How Arc resolves the event source definition, stream, stream id and concurrency scope for a command's append, and what it hands to Chronicle. This page follows one command from its `[EventSource(stream)]` attribute to the append Chronicle receives. Read it when a command appends to a different stream than you expected, fails with a routing exception, or conflicts less (or more) than you expected. For the declarations themselves, see [Route a command through an event source definition](/arc/backend/csharp/chronicle/commands/event-source-definitions/). ## From the attribute to a route Arc builds the command’s routing in the command context before `Handle()` runs. Four things are resolved separately and then combined into one `EventRoute`. | Part | Resolved from | When | | --- | --- | --- | | Event source definition | `[EventSource]`, looked up in the event store’s discovered definitions | When the command context is built | | Event source type and stream type | The definition’s name and the named stream; the stream type is `All` when no stream is named | With the definition | | Event source id | The command’s [event source id conventions](/arc/backend/csharp/chronicle/commands/events/#event-source-id-resolution) | When the command context is built | | Stream id | `[EventStreamId]`, a template in it, or `ICanProvideEventStreamId`; the default stream id when none | A constant or interface when the context is built; a template on first use after validation | When the context is built, Arc: 1. Reads the one `[EventSource]` on the command type. 2. Resolves `TSource` from the discovered definitions. An unregistered type throws `UnknownEventSource`. 3. Looks up the named stream. A name the definition does not declare throws `EventRoutingContradictsEventSource`. 4. Compares the definition with `[EventSourceType]` and `[EventStreamType]` on the command. A value that differs from the definition’s source name, or from the stream’s name (`All` without a stream), throws `EventRoutingContradictsEventSource`. 5. Stores the definition type, the stream name, the source type, the stream type and the concurrency dimensions in the command context. The concurrency dimensions are the stream’s own when it declares any, otherwise the event source’s. Chronicle owns that rule in `EventSourceDefinition.ConcurrencyFor`. The stream id is not part of the definition. A template is resolved on first use, after authorization and validation, so a validator can reject a missing property before the template throws. `CommandContext.GetEventRoute()` returns the combined route, and `IStreamReads.ForCommand()` and `CompleteStream` use it. `IEventRoutes.For(stream)` builds the same route outside a command, with the default stream id until you call `WithStreamId(...)`. ## From the route to an append When `Handle()` returns events, Arc appends them through the definition. The append carries the definition type, the stream name, the stream id and the event source id, so Chronicle records the definition name in `EventContext.EventSource` and the resolved source and stream types on the event. Chronicle rejects a stream the definition does not declare. Per-event routing changes this for one returned event: * An `EventForEventSourceId` that sets `EventSource` and `EventStream` appends through that definition and stream. * One that sets only `EventStream` uses the command’s definition and that stream. * One that sets the legacy `EventSourceType` or `EventStreamType` strings appends by those strings and does not use the definition. Arc rejects an undeclared stream string with `EventStreamDoesNotBelongToEventSource` when it names a known definition, and the framework’s analyzers [ARCCHR0020](/arc/backend/csharp/chronicle/code-analysis/arcchr0020/) and [ARCCHR0021](/arc/backend/csharp/chronicle/code-analysis/arcchr0021/) flag the literal forms. * `EventStreamId` and `Subject` set on the wrapper always win over the command. [Per-event routing](/arc/backend/csharp/chronicle/commands/events/#per-event-routing) has the full rules. ## The concurrency scope Arc passes Arc decides per returned event which scope, if any, accompanies the append. The first match applies. | Situation | Scope passed with the append | | --- | --- | | The command returns `EventsWithConcurrencyScopes` | The returned scopes, which replace everything below | | A command attribute declares `concurrency: true` | A scope built from those attributes’ dimensions at the strategy’s expected tail | | The command’s definition declares no dimensions | None; the event sequence applies its configured strategy | | A plain event, or a collection of plain events, and the command’s definition declares dimensions | A scope for the event source id, narrowed by the declared stream type, stream id and event source type, at the strategy’s expected tail | | An `EventForEventSourceId` routed through a definition, and the command’s definition declares dimensions | None from Arc; the event sequence derives the guard from that event’s own definition and stream | | An `EventForEventSourceId` routed by legacy strings, and the command’s definition declares dimensions | A scope built from that event’s own routing | Three details follow from this: * **Which dimensions the scope carries depends on the path.** The scope Chronicle derives from a definition carries exactly the declared dimensions, so a definition without `EventSourceId` guards the stream across event sources. The scope Arc builds itself for plain returned events always carries the event source id and adds the declared stream type, stream id and source type. Declare `EventSourceId` on the definition to make both paths agree. * **One guard per route.** When several returned events target the same id through a definition, Chronicle derives one guard per distinct route and sends them together in one batch; a violation of any of them refuses the whole batch. Events that need the same guard share one. This needs a Chronicle kernel 19.84 or later: against an older kernel the client throws `IncompatibleConcurrencyScopesForEventSource` before anything is appended. Events that override the definition with legacy routing and need different guards for one id keep every distinct guard: Arc sends them all, in event order, and Chronicle requires each one to hold. Guards for the same stream and event types collapse to one. Mixing such legacy guards with a guard Chronicle derives from a definition for the same id is rejected by Arc with the same exception, because Chronicle stops deriving once it is handed a scope for the id. Split the command, or return `EventsWithConcurrencyScopes` with the guard you want. * **Timing.** For plain events, Arc resolves the expected tail while it handles the response, after `Handle()`. For an event whose guard Chronicle derives, the tail is resolved when the unit of work commits. In both cases it is not necessarily the revision `Handle()` read. The first append into an empty scope is checked only when the client’s concurrency options opt in. A stream decision avoids the timing gap: `IStreamReads` captures the tail before it reads the stream and builds the exact scope, with the event source id, event source type, stream type and stream id from the route (and no stream id for `StreamType(...)`). `StreamDecision.Append(...)` returns it in `EventsWithConcurrencyScopes`, which replaces the derived scopes. When the stream is empty, the scope expects no matching event. The definition’s dimensions are not applied to it: the exact scope always carries the route’s source, stream type and stream id. ## How this maps to the Dynamic Consistency Boundary A scope is the append condition: it names the events the append must not have missed (a query) and the position the decision was based on. In Chronicle the stream metadata plays the role of DCB tags, and an event source definition is the typed way to choose which dimensions guard an append. The dimensions choose the query; the expected tail is the position. Because the query never names event types, a definition scope guards the whole source or stream, a coarse, aggregate-like boundary: right for a cohesive history, too wide when unrelated events share the stream. See [Chronicle’s Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/) for the model and [choosing a mechanism](/chronicle/dynamic-consistency-boundary/choosing/) for the guards. A definition gives the query a name and a place to live. It does not capture the position at the time of your read; a stream decision, a protected decision or an exact scope does. ## Diagnose a routing problem | Symptom | Cause | | --- | --- | | `UnknownEventSource` when the command runs | The definition is not discovered by the Chronicle client for this event store | | `EventRoutingContradictsEventSource` | The stream name is undeclared, or `[EventSourceType]` or `[EventStreamType]` disagrees with the definition | | `AmbiguousEventStreamId` | A non-empty `[EventStreamId]` and `ICanProvideEventStreamId` are both declared | | `UnknownEventStreamIdTemplateProperty`, `InvalidEventStreamIdTemplate`, `EventStreamIdTemplatePartMissing` | A template names a property that is not a public readable instance property, has malformed braces, or resolves a part or the whole id to blank or the default | | `EventStreamDoesNotBelongToEventSource` | A returned wrapper names an undeclared stream by legacy strings on a definition-routed command | | `IncompatibleConcurrencyScopesForEventSource` | Returned events for one event source id mix legacy-routed guards with a guard Chronicle derives from a definition, or need several guards against a Chronicle kernel older than 19.84 | | `StreamDecisionRequiresSpecificRoute` | `IStreamReads` was asked to read a route without an event source id, a stream type or a stream id | | No conflict where you expected one | The check is the default append check, or the definition declares no dimensions, or the dimensions do not include the one that differs; see [what the definition guards](/arc/backend/csharp/chronicle/commands/event-source-definitions/#what-the-definition-guards) | ## Where to go next * [Scenarios](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/): complete commands for each stream shape. * [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/): the attribute-based scope and the exact-scope rules. * [Stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/) and [completing streams](/arc/backend/csharp/chronicle/commands/completing-streams/): what builds on the route. * [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/). # Event source definition scenarios > Route commands through an event source definition for a stream per entity, per owner and period, and per process, then decide against a stream and complete one. Use this page when you know the shape of the stream you need and want the command. Each scenario states the shape, names one recommended way with the reason, shows a complete definition and command, says what the append is checked against, and lists the alternatives. They follow [Route a command through an event source definition](/arc/backend/csharp/chronicle/commands/event-source-definitions/); read that page first for the declarations. | Shape | Event source id | Stream | Stream id | | --- | --- | --- | --- | | [Per entity](#a-stream-per-entity) | The entity | One stream | Default | | [Per owner and period](#a-stream-per-owner-and-period) | The report | One stream | `{Owner}:{Period}` | | [Per process](#a-stream-per-process) | The customer | One stream | `{ProcessId}` | One boundary note applies to all three: a definition scope and a stream decision guard the whole source or stream, whatever the event type. In Dynamic Consistency Boundary terms that is a coarse, aggregate-like boundary. It is right when every event in the stream matters to the decisions made against it, which is why these examples use short, cohesive streams. Chronicle has one event source per event. Every shape here chooses a stream within one event source; a rule over several entities is several events, one per event source, in one batch. See [Chronicle’s DCB scenarios](/chronicle/dynamic-consistency-boundary/scenarios/) for that side. ## A stream per entity **Shape:** everything that happens to one account is one history, and a competing write to the same account conflicts. **Recommended:** a definition with the `EventSourceId` dimension and one named stream, and `[EventSource(stream)]` on the command. The reason: the stream and its guard are declared once and typo-checked. If one unnamed history per account is enough, you need no definition: the command key and the default append already give that boundary. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSources; [EventSource("Account", Concurrency = ConcurrencyDimensions.EventSourceId)] [EventStream("Transactions")] public class AccountEventSource : IEventSource; [EventType] public record TransactionRecorded(decimal Amount); [Command] [EventSource("Transactions")] public record RecordTransaction(EventSourceId AccountId, decimal Amount) { public TransactionRecorded Handle() => new(Amount); } ``` The event lands on event source `AccountId`, event source type `Account`, stream type `Transactions`, default stream id. The dimension is on the event source, so the `Transactions` stream inherits it. The append is checked against all events for the same event source id, of any type. This is the default append check: it does not bind `Handle()`’s decision to what it read. For a balance rule, use a read model parameter under [read protection](/arc/backend/csharp/chronicle/consistency/getting-started/) `On`, or an [aggregate](/arc/backend/csharp/chronicle/aggregates/injecting-into-commands/). **Alternatives:** `[EventStreamType("Transactions")]` and `[EventSourceType("Account")]` strings (ARCCHR0013 suggests the definition); an aggregate root when the account has rich behaviour; an exact scope when the caller supplies the revision it saw. ### Per-stream monthly state For a monthly withdrawal limit, add `[EventStream("Transactions")]` to a source-keyed withdrawals model and route the command with `[EventStreamId("{Month}")]`. Take the model in a validator under `On`; no decision token belongs on the command. The [complete monthly-limit example](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/#a-monthly-limit) uses a definition without source-wide `Concurrency`, so October is guarded and November does not conflict. The source-wide policy in the per-entity example above would widen that guard to the account’s whole history. ## A stream per owner and period **Shape:** a report is approved once per owner and month. Another owner’s month, and another month of the same owner, are independent. **Recommended:** a template in `[EventStreamId]` on a definition-routed command. The reason: it is one attribute, the id is built the same way for the append, a read and a completion, and `EventStreamIdTemplate.ResolveFor(command)` reuses it in tests. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSources; [EventSource("Report")] [EventStream("Approval", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)] public class ReportEventSource : IEventSource; [EventType] public record ReportApproved; [Command] [EventSource("Approval")] [EventStreamId("{Owner}:{Period}")] public record ApproveReport(EventSourceId ReportId, string Owner, DateOnly Period) { public ReportApproved Handle() => new(); } ``` For `Owner = "acme"` and `Period = 2026-10-01`, the append targets event source `ReportId`, stream type `Approval`, stream id `acme:2026-10-01`. The `Approval` stream declares its own dimensions, so the check is narrowed to events with that stream type and stream id for the report. A write to `acme:2026-11-01` is not part of it. The template reads public properties of the command. Arc normally resolves it after validation. A per-stream validator parameter needs the route earlier, so Arc resolves it before constructing that validator; a blank part returns `400` naming the property without reading. Keep the id deterministic: two commands that compute different ids for the same period do not conflict. **Alternatives:** `ICanProvideEventStreamId` when code computes the id and a template cannot express it; `[EventStreamType]` and `[EventStreamId]` strings without a definition. ### Decide against the stream **Shape:** the same report, but approving twice must not record a second approval, and two simultaneous approvals must not both succeed. **Recommended:** a [stream decision](/arc/backend/csharp/chronicle/commands/stream-decisions/), because the rule is “has this fact already happened in this stream” and the facts are raw events in one short stream. Alternatives: a read model parameter under [read protection](/arc/backend/csharp/chronicle/consistency/getting-started/) `On` when the rule needs folded state or several keys, and a constraint when the rule is only uniqueness. Add the stream decision. `ForCommand()` reads the stream the command declares, definition and template included, and `Append(...)` returns the events with an exact scope at the tail it read. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Chronicle.Streams; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSources; [EventSource("Report")] [EventStream("Approval", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)] public class ReportEventSource : IEventSource; [EventType] public record ReportApproved; [Command] [EventSource("Approval")] [EventStreamId("{Owner}:{Period}")] public record ApproveReportOnce(EventSourceId ReportId, string Owner, DateOnly Period) { public Task Provide(IStreamReads reads) => reads.ForCommand(); public EventsWithConcurrencyScopes Handle(StreamDecision decision) => decision.Has() ? decision.Append([]) : decision.Append(new ReportApproved()); } ``` What this guards: * The tail of `Approval` / `acme:2026-10-01` for this report is captured before the events are read. * A competing append to that stream after the read rejects the command, including when both commands attempt the first append. * A second approval finds `ReportApproved` and returns an empty append: an identical retry, which succeeds without a check. * A competitor on another stream id, such as another month, does not conflict. Use `reads.StreamType(...)` to guard every stream id of the type. The scope covers every event in the stream, not only `ReportApproved`, and the whole stream crosses the wire on each command. Keep these streams short and cohesive: a period is a good stream, the life of the company is not. See [stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/) for the exact-scope rules and [how a stream decision differs from a decision read](/arc/backend/csharp/chronicle/consistency/faq/#event-source-definitions-and-stream-decisions). Test the guard by resolving the route the definition produces and letting a competitor append to it before the command commits. The command must produce a new fact for the guard to apply, so do not seed `ReportApproved`: that takes the identical-retry branch, which carries no scope. This excerpt assumes the definition and command above: ```csharp using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Chronicle.Events; await using var scenario = new CommandScenario(); var id = EventSourceId.New(); var route = scenario.EventRoutes.For("Approval").WithStreamId("acme:2026-10-01"); scenario.AppendConcurrently(id, route, new ReportApproved()); ``` ### Per-stream approval state When the rule needs a count or a folded status, use a per-stream model instead of reading raw events. This excerpt reuses `ReportEventSource` and `ReportApproved` above. Under `On`, the validator prevents a second approval and a concurrent approval in the same owner/period refuses the commit with `ReadChanged`; another period does not conflict. ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Queries.ModelBound; using Cratis.Chronicle.Projections.ModelBound; using Cratis.Chronicle.ReadModels; using FluentValidation; public record ReportId(string Value) : EventSourceId(Value); [ReadModel, EventStream("Approval")] public record PeriodApproval( ReportId Id, [Increment] int Approvals, [SetValue(true)] bool Approved); [Command, ReadProtection(ReadProtectionMode.On)] [EventSource("Approval")] [EventStreamId("{Owner}:{Period}")] public record ApprovePeriod(ReportId Report, string Owner, DateOnly Period) { public ReportApproved Handle() => new(); } public class ApprovePeriodValidator : CommandValidator { public ApprovePeriodValidator(PeriodApproval? approval) => RuleFor(command => command.Report) .Must(_ => approval?.Approved != true) .WithMessage("This period is already approved."); } ``` The same shape can enforce a required number of approvals using `Approvals`. Keep the [stream decision](#decide-against-the-stream) for the raw “approved once” check when no folded model is needed. Do not combine its later boundary with a validator-first read. ## A stream per process **Shape:** each customer onboarding is its own process with its own id. When the process ends, nothing more may be appended to it, and another customer’s process with the same id stays open. **Recommended:** a stream per process id on a definition-routed command, and `CompleteStream` returned by the command that ends the process. The reason: the command already declares the route, so the completion needs no repeated type or id. Alternatives: `route.Complete()` from a reactor or another command, and an exact `CompleteStream(streamType, streamId)` when the target differs from the command’s route. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Chronicle.Streams; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSources; [EventSource("Customer", Concurrency = ConcurrencyDimensions.EventSourceId)] [EventStream("Onboarding", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)] public class CustomerEventSource : IEventSource; [EventType] public record OnboardingStepCompleted(string Step); [EventType] public record OnboardingFinished; [Command, Cratis.Arc.Commands.ReadProtection(Cratis.Arc.Commands.ReadProtectionMode.On)] [EventSource("Onboarding")] [EventStreamId("{ProcessId}")] public record CompleteOnboardingStep(EventSourceId CustomerId, string ProcessId, string Step) { public OnboardingStepCompleted Handle() => new(Step); } [Command, Cratis.Arc.Commands.ReadProtection(Cratis.Arc.Commands.ReadProtectionMode.On)] [EventSource("Onboarding")] [EventStreamId("{ProcessId}")] public record FinishOnboarding(EventSourceId CustomerId, string ProcessId) { public (OnboardingFinished, CompleteStream) Handle() => (new(), new()); } ``` `CompleteOnboardingStep` appends to event source `CustomerId`, stream type `Onboarding`, stream id `ProcessId`. `FinishOnboarding` appends the final event and then completes that stream. ### Per-stream onboarding state This excerpt reuses the onboarding definitions and commands above. `OnboardingProgress` counts completed steps in this customer’s process. The validators enforce the order `identity → address → consent` and require all three before `FinishOnboarding` returns its final event and `CompleteStream`. ```csharp [ReadModel, EventStream("Onboarding")] public record OnboardingProgress( EventSourceId Id, [Increment] int Steps); public class CompleteOnboardingStepValidator : CommandValidator { public CompleteOnboardingStepValidator(OnboardingProgress? progress) { string[] steps = ["identity", "address", "consent"]; var completed = progress?.Steps ?? 0; RuleFor(command => command.Step) .Must(step => completed < steps.Length && step == steps[completed]) .WithMessage("Complete the next onboarding step in order."); } } public class FinishOnboardingValidator : CommandValidator { public FinishOnboardingValidator(OnboardingProgress? progress) => RuleFor(command => command.ProcessId) .Must(_ => progress?.Steps == 3) .WithMessage("Complete every onboarding step first."); } ``` The per-stream model is read before either validator runs and is guarded under `On`. Concurrent activity in this process refuses the commit; another process id does not. Completion still happens only after commit, not as part of the atomic append. ### Complete the stream `new CompleteStream()` takes the stream type and stream id from the command’s resolved route, so it closes `Onboarding` / `ProcessId`. Because the command has an event source id, Arc scopes the completion to that customer: another customer’s process with the same id stays open. Details and limits: * Arc completes the stream only after the command’s events commit. The append and the completion are separate operations with no expected-tail guard between them. * A later append to the completed stream, from any command, fails with a validation result naming the closed stream. * The default stream id cannot be completed. A command that declares no stream id gets a validation failure. * Completing an already completed stream succeeds, so a repeated completion is safe. * Completion needs Chronicle kernel 19.39 or later. A reactor or another command can close the same stream from a route instead of repeating the declaration: `routes.For("Onboarding").WithStreamId(processId).Complete()` returns the `CompleteStream` for it. See [completing streams](/arc/backend/csharp/chronicle/commands/completing-streams/) for reactors and for asserting completion in a scenario. ## Where to go next * [How routing works](/arc/backend/csharp/chronicle/commands/event-source-definition-routing/): what Arc does from the attribute to the append. * [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) and its [scenarios](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/#a-period-approval): when a stream decision is the right guard. * [Chronicle’s Dynamic Consistency Boundary scenarios](/chronicle/dynamic-consistency-boundary/scenarios/) and [event sources](/chronicle/event-sources/): stream metadata plays the role of DCB tags, and a definition is the typed way to choose which dimensions guard an append. # Route a command through an event source definition > Declare the event source and stream a command appends to with [EventSource], and learn which concurrency boundary the definition gives the append. An event source definition is a class that names a kind of event source and the streams it owns. A command that declares `[EventSource(stream)]` appends its events through that definition instead of through loose strings. The definition supplies the event source type, the stream type and the concurrency dimensions, and Arc rejects a stream the definition does not declare. Use this guide when a command should append to a named stream and you want its routing and its concurrency boundary declared once, in one typed place, instead of as strings on each command. A definition gives the append a **boundary**; it does not make a decision hold. [What the definition guards](#what-the-definition-guards) says exactly what you get, and [choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) tells you what to add when the rule depends on state. The definition is a Chronicle concept; see [Chronicle event sources](/chronicle/event-sources/) for how Chronicle registers and stores it. This page covers what Arc does with it. ## Declare the definition and the command The definition is a class implementing `IEventSource`, marked `[EventSource]` and one `[EventStream]` per stream. Without an explicit name, the event source name is the class name with a trailing `EventSource` removed. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSources; [EventSource("Account")] [EventStream("Transactions", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType)] public class AccountEventSource : IEventSource; [EventType] public record TransactionRecorded(decimal Amount); [Command] [EventSource("Transactions")] public record RecordTransaction(EventSourceId AccountId, decimal Amount) { public TransactionRecorded Handle() => new(Amount); } ``` The event is appended with: * event source type `Account`, from the definition name; * event stream type `Transactions`, from the stream you named; * the `AccountId` as event source id, resolved by the usual [event source id conventions](/arc/backend/csharp/chronicle/commands/events/#event-source-id-resolution); * the default stream id, because the command declares none. The command’s namespace needs `Cratis.Arc.Chronicle.Commands` for `[EventSource]` and `Cratis.Chronicle.EventSources` for the definition types. The definition must be discovered by the Chronicle client like any other artifact; an unregistered definition type fails when the command runs. A command declares at most one `[EventSource]`. The stream argument is optional: without it the command routes through the definition’s source type with the default stream type. ## Choose the stream A stream is the part of an event source’s history that one kind of decision shares. Pick the stream by asking which events must be checked against each other when two commands run at once. **Recommended:** one definition per kind of entity, one `[EventStream]` per history that is decided separately, and the event source id as the entity’s id. Name the stream on the command with `[EventSource(stream)]`. The reason: the definition is the single place that says which streams exist and which dimensions guard them, and Arc rejects a typo. Alternatives, in order of preference: the command key and default stream with no definition, when one history per entity is enough; string attributes (`[EventSourceType]` with `[EventStreamType]`), which ARCCHR0013 flags once a matching definition exists; explicit scopes, when the decision must be bound to a revision it read. | You want | Declare | Stream id | | --- | --- | --- | | One history per entity, everything for it together | One stream, or none | Leave unset; the event source id identifies the entity | | Independent histories of one entity, such as `Onboarding` and `Transactions` | One `[EventStream]` per history | Leave unset | | One history per entity and period, owner or process | One stream | A template or `ICanProvideEventStreamId`, see below | `EventStream` on the attribute must name a stream the definition declares. A typo or an undeclared name fails when the command’s context is built, with `EventRoutingContradictsEventSource`, instead of appending to a stream nobody reads. [Scenarios](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/) shows each shape as a complete command. ## Choose the stream id The definition names the stream. The stream id is separate: it picks one concrete stream within the stream type, and the definition does not supply it. Declare it on the command with the existing stream id mechanisms: * `[EventStreamId("constant")]` for a fixed id. * `ICanProvideEventStreamId` when code computes it. * A template in `[EventStreamId("{Owner}:{Period}")]` when it is composed from command properties. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSources; [EventSource("Report")] [EventStream("Approval", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)] public class ReportEventSource : IEventSource; [EventType] public record ReportApproved; [Command] [EventSource("Approval")] [EventStreamId("{Owner}:{Period}")] public record ApproveReport(EventSourceId ReportId, string Owner, DateOnly Period) { public ReportApproved Handle() => new(); } ``` For `Owner = "acme"` and `Period = 2026-10-01`, the stream id is `acme:2026-10-01`. A template part is a public instance property. Concepts use their underlying value, `DateOnly` uses `yyyy-MM-dd`, and `{{` and `}}` are literal braces; the [full format table](/arc/backend/csharp/chronicle/commands/concurrency/#template-event-stream-ids) lists the rest. Arc refuses unknown properties, blank parts, malformed braces and an id that resolves to the default, and it resolves the template after validation, so a validator can reject a missing part first. Use the same function when you read or seed the stream: `EventStreamIdTemplate.ResolveFor(command)` returns the id exactly as the pipeline resolves it. Combining a non-empty `[EventStreamId]` with `ICanProvideEventStreamId` is ambiguous and throws `AmbiguousEventStreamId`. ## What the definition guards Chronicle’s Dynamic Consistency Boundary (DCB) selects the events an append must not have missed with *tags*. In Chronicle the stream metadata plays that role: event source id, event source type, stream type and stream id. An event source definition is the typed way to choose which of those dimensions guard an append. The concurrency dimensions on the definition decide which events a returned event is checked against. `[EventSource]` can declare dimensions for every stream, and each `[EventStream]` can declare its own; a stream that declares dimensions replaces the source’s for that stream, and a stream that declares none inherits the source’s. The dimensions are a combination of: | Dimension | The append is checked against events with the same | | --- | --- | | `EventSourceId` | Event source id. Leave it out only when the guard should span every event source that writes the stream, and only on a definition-routed `EventForEventSourceId`; see below. | | `EventSourceType` | Event source type, the definition name | | `EventStreamType` | Stream type, the stream name | | `EventStreamId` | Stream id, from `[EventStreamId]` | With no dimensions declared, the definition adds no guard of its own and the event sequence applies its configured strategy as it does for any append. Which dimensions the scope carries depends on how the event is returned. A definition-routed `EventForEventSourceId`, single or in a returned collection of wrappers (`EventSource` set, or built with `IEventRoutes`), is guarded by the scope Chronicle derives from the definition, which carries exactly the declared dimensions (when the command’s definition declares any), so a definition without `EventSourceId` is checked against all sources’ events with the same stream type and stream id. For every other returned event, that is a plain event, a collection of plain events, or a legacy-string wrapper, Arc builds the scope itself and always includes the target event source id, then adds the declared stream type, stream id and source type. Omitting `EventSourceId` therefore gives no cross-source guard there. Do not rely on a cross-source boundary from a plain returned event; include `EventSourceId` on the definition so both paths agree, or return definition-routed events. [How routing works](/arc/backend/csharp/chronicle/commands/event-source-definition-routing/) lists each return path. Four limits matter: * **It is the default append check, not a decision guard.** Arc resolves the expected tail while it processes the returned events, after `Handle()` has run. A concurrent append between your read and that moment is part of the new expected tail and does not conflict. The first append into an empty scope is checked only when the client’s concurrency options opt in. To bind a decision to what it read, use a [stream decision](/arc/backend/csharp/chronicle/commands/stream-decisions/), a read model parameter under [read protection](/arc/backend/csharp/chronicle/consistency/getting-started/) `On`, or [exact scopes](/arc/backend/csharp/chronicle/commands/events/#events-with-exact-concurrency-scopes). * **An explicit choice wins.** `concurrency: true` on `[EventStreamId]`, `[EventStreamType]` or `[EventSourceType]` selects the dimensions from those attributes, and a returned `EventsWithConcurrencyScopes` replaces every derived scope. The definition stays the policy for every command that does not override it. * **It guards the whole source or stream, whatever the event type.** The scope narrows by event source id, source type, stream type and stream id, never by event type. In DCB terms that is a coarse, aggregate-like boundary: any new event in the guarded stream conflicts, related or not. That is the right shape when the events in the stream are all relevant to the decisions made against it, for example a short history per entity or per period. When unrelated event types share the stream, give the decision its own stream, or take a read model parameter under read protection `On`, whose guard narrows to the event types its model folds. A [stream decision](/arc/backend/csharp/chronicle/commands/stream-decisions/) has the same whole-stream boundary and also reads the whole stream. * **One event source per event.** The boundary is an event source id plus the dimensions above. Chronicle does not tag one event with several entities, so a fact about two entities is two events, one per event source, appended in one atomic batch. See [Chronicle’s Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/) for how a concurrency scope maps to the boundary and [when to use which mechanism](/chronicle/dynamic-consistency-boundary/choosing/). [How routing works](/arc/backend/csharp/chronicle/commands/event-source-definition-routing/) follows a single append from the attribute to the scope Chronicle receives. ### Declare concurrency sparingly A declared `Concurrency` makes every append to the stream conflict with every other append in the boundary. Declare it only for ledgers, numbering and approval streams, where every writer must see every other. For anything else it adds conflicts and does not make a rule hold. Under read protection `On`, the declared dimensions are merged with the guard of a read of the same source at commit. The declared dimensions are kept only where they equal the read’s, and the event types become any type, so the guard gets wider. A declaration without `EventSourceId` is kept as a separate guard that is not tied to the read, so it does not protect the state you read. [How it works](/arc/backend/csharp/chronicle/consistency/how-it-works/#declared-concurrency-on-a-definition) shows an example, and Chronicle’s [CHR0062](/chronicle/code-analysis/chr0062/) reports a declaration without `EventSourceId`. ## Reading and completing the routed stream The route the command declares is the same route its reads and completions use: * [`IStreamReads.ForCommand()`](/arc/backend/csharp/chronicle/commands/stream-decisions/) reads the command’s stream at a captured tail and returns a `StreamDecision` whose `Append(...)` carries an exact scope. * [`CompleteStream`](/arc/backend/csharp/chronicle/commands/completing-streams/) closes the command’s stream after its events commit. * `IEventRoutes` resolves a route for any definition outside a command; see [per-event routing](/arc/backend/csharp/chronicle/commands/events/#per-event-routing). ## Migrate from string routing Commands without a definition keep working. To move one over: 1. Declare an `IEventSource` with `[EventSource("Name")]` and an `[EventStream]` for each stream type the command used. Use the strings you already store as the names so existing events and observers match. 2. Replace `[EventSourceType("Name")]` and `[EventStreamType("Stream")]` on the command with `[EventSource("Stream")]`. Keep `[EventStreamId]` and `ICanProvideEventStreamId`; the definition does not replace them. 3. Move `concurrency: true` to dimensions on the definition. While a command still carries the flag, the flag chooses the dimensions and the definition’s are ignored for it. [ARCCHR0013](/arc/backend/csharp/chronicle/code-analysis/arcchr0013/) (Info) finds the commands to migrate: when `[EventSourceType("Name")]` on a command or aggregate root names a source that a definition in the compilation declares, it suggests `[EventSource]`, or `[EventSource("stream")]` when an `[EventStreamType]` names a stream the definition declares. It stays silent when the stream is not declared, and for a type that already has `[EventSource]`. It has no code fix, because the attributes can carry `concurrency: true` flags that the definition’s dimensions do not reproduce. Do not keep a string attribute that contradicts the definition. `[EventSourceType]` or `[EventStreamType]` with a value other than the definition’s source or stream fails with `EventRoutingContradictsEventSource` rather than append events whose strings disagree with the definition. ### Wrapper-named streams A returned `EventForEventSourceId` can name its route with `EventSourceType` and `EventStreamType` strings. That still works, but the event is appended by those strings: it loses `EventContext.EventSource` and the wrapper target’s own definition policy. On a definition-routed command Arc still applies the command’s selected definition dimensions, and builds the scope from the wrapper’s own routing strings, always including the target event source id. Two analyzers help you migrate: * [ARCCHR0021](/arc/backend/csharp/chronicle/code-analysis/arcchr0021/) (Info) finds literal strings that spell a declared stream. Its code fix replaces them with `EventSource = typeof(Definition)` and `EventStream = "stream"`, or you can use `IEventRoutes`. Review the concurrency change first, because the guard now comes from the definition, which Chronicle derives with exactly the declared dimensions rather than Arc’s scope that always includes the event source id. * [ARCCHR0020](/arc/backend/csharp/chronicle/code-analysis/arcchr0020/) (Warning) finds a literal stream name that the definition does not declare, which is usually a typo. At runtime a definition-routed command rejects such a wrapper with `EventStreamDoesNotBelongToEventSource` before it enrolls or appends anything. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Chronicle.Streams; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSources; [EventSource("Account")] [EventStream("Transactions")] [EventStream("Adjustments")] public class AccountEventSource : IEventSource; [EventType] public record BalanceAdjusted(decimal Amount); [Command] [EventSource("Transactions")] public record AdjustAccount(EventSourceId AccountId, decimal Amount) { // Before: new EventForEventSourceId(AccountId, new BalanceAdjusted(Amount)) // { EventSourceType = "Account", EventStreamType = "Adjustments" } public EventForEventSourceId Handle(IEventRoutes routes) => routes.For("Adjustments").Route(AccountId, new BalanceAdjusted(Amount)); } ``` ## Where to go next * [Scenarios](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/): a stream per entity, per owner and period, and per process, with a stream decision and a completed stream. * [How routing works](/arc/backend/csharp/chronicle/commands/event-source-definition-routing/): how Arc resolves the definition, stream, stream id and scope. * [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) and its [FAQ](/arc/backend/csharp/chronicle/consistency/faq/#event-source-definitions-and-stream-decisions): where definitions fit among the guards. * Chronicle: [event sources](/chronicle/event-sources/), [Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/), [choosing a mechanism](/chronicle/dynamic-consistency-boundary/choosing/), [scenarios](/chronicle/dynamic-consistency-boundary/scenarios/) and [FAQ](/chronicle/dynamic-consistency-boundary/faq/). # Event tags > Attach structured named tags to every event a command returns, from properties, handler results, or application-wide conventions. When events from several streams belong to the same project or import, their event source ids alone do not capture that relationship. Add named tags to the command to carry that grouping on every event it returns, without changing the events’ payloads or routing. These examples require [Arc’s Chronicle integration](/arc/backend/csharp/chronicle/). The tag APIs below live in `Cratis.Arc.Chronicle.Commands`; `NamedTag` lives in `Cratis.Chronicle`. ## Tag from command properties Use `[EventTag]` for a property value or a constant: ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] [EventTag("projectId", nameof(ProjectId))] [EventTag("origin", Value = "import")] public record ImportTask(EventSourceId TaskId, string ProjectId, string Title) { public TaskImported Handle() => new(Title); } [EventType] public record TaskImported(string Title); ``` For `ProjectId = "project-42"`, `TaskImported` carries the named tags `projectId=project-42` and `origin=import`. You can declare multiple attributes. A concept property contributes its underlying value; other values use `ToString()`, with invariant culture for `IFormattable` values. Each attribute must specify exactly one source: a property name or `Value`. Both or neither cause `AmbiguousEventTagValue`. A property must be a readable public instance property without index parameters; an invalid name or unreadable property causes `UnknownEventTagProperty`. A null property value, or a value that renders as null, causes `EventTagValueMissing` rather than silently omitting a tag. Empty strings are valid tag values. ## Compute tags on the command Implement `ICanProvideEventTags` when tags need logic. This alternative uses the `TaskImported` event from the previous example: ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record ImportTaskWithCategory(EventSourceId TaskId, string Title, bool Urgent) : ICanProvideEventTags { public IEnumerable GetEventTags() => [new("priority", Urgent ? "urgent" : "normal")]; public TaskImported Handle() => new(Title); } ``` Arc combines these tags with attribute tags, so you can use both approaches on the same command. ## Return tags with events If the value is known only after the handler’s decision, return `EventTags` alongside the event. This alternative also uses `TaskImported`: ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record ImportTaskWithBatch(EventSourceId TaskId, string Title) { public (TaskImported, EventTags) Handle() { var batchId = Guid.NewGuid().ToString(); return (new(Title), new EventTags(new NamedTag("batchId", batchId))); } } ``` Arc merges returned tags before appending any events in the tuple, regardless of the tuple order. `EventTags` is metadata, not an event collection or a client response. Returning it alone appends nothing. ## Apply an application-wide convention Implement `ICanProvideCommandEventTags` in an application service to add shared grouping tags. Arc discovers every implementation by convention; no manual registration is needed. For example, this provider tags commands by their type name: ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle; public class CommandKindTags : ICanProvideCommandEventTags { public IEnumerable GetEventTags(object command) => [new("commandKind", command.GetType().Name)]; } ``` Arc resolves providers per command from the command’s service scope, so providers may take scoped dependencies such as tenant services. Providers run only after the command passes authorization and validation and returns events to append; validation-only calls and commands without returned events do not evaluate tags. Each provider receives the command instance and can return an empty collection for commands it does not apply to. Keep providers transient or scoped when they depend on request, user, or tenant services; do not capture those values in a singleton. Arc evaluates attributes, the command interface and application providers once, at the first returned-event append. Tags returned from `Handle()` join that union. `commandContext.GetEventTags()` reads only returned tags without evaluating any providers; `commandContext.ResolveEventTags()` resolves the union at append time. For a manually constructed context without a service provider or Arc’s type discovery, resolution includes attribute, command-interface and returned tags, but no application-wide providers. ## Combine command and per-event tags All command sources form a union, distinct by the pair `(Name.Value, Value)`. Equal pairs occur once; the same name with different values keeps both values. A returned `EventForEventSourceId` preserves its own `NamedTags` and adds the command tags, with wrapper tags first. For an individual event, set its tags on the wrapper (handler excerpt using `TaskImported`): ```csharp using Cratis.Chronicle; using Cratis.Chronicle.EventSequences; public EventForEventSourceId Handle() => new(TaskId, new TaskImported(Title)) { NamedTags = [new NamedTag("sourceFile", "tasks.csv")] }; ``` The same merge applies to single events, plain batches, mixed batches, and `EventsWithConcurrencyScopes`, both in a command transaction and in an immediate append. Legacy string `Tags`, routing, and per-event occurrence times are unchanged. When there are no named tags, Arc keeps its existing append calls. An alternate Chronicle event sequence or unit-of-work implementation must support named tags to accept tagged events; unsupported implementations fail rather than discard the tags. Command tags apply to returned events, not to aggregate commits or events you append manually. Continue with [Events](/arc/backend/csharp/chronicle/commands/events/) for the supported handler return shapes and [Transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/) for the commit boundary. # Events > What a command Handle() can return — a single event, several, a tuple, a Result, or nothing — and what Chronicle does with each. When a [model-bound](/arc/backend/csharp/commands/model-bound/) command handler returns an event (or a collection of events), Chronicle appends those events to the event log automatically. This lets you keep command handlers focused on decisions and domain rules instead of event log plumbing. Return-driven appends enroll in the command’s pending transaction: the batch commits atomically on success and rolls back on failure, unless code explicitly completed that shared transaction earlier — see [Transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/). ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] public record RegisterCustomer(EventSourceId CustomerId, string Email) { public CustomerRegistered Handle() { return new CustomerRegistered(Email); } } [EventType] public record CustomerRegistered(string Email); ``` You can also return multiple events as a collection: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] public record UpdateCustomerProfile(EventSourceId CustomerId, string DisplayName, string Email) { public IEnumerable Handle() { return new object[] { new CustomerDisplayNameChanged(DisplayName), new CustomerEmailChanged(Email) }; } } [EventType] public record CustomerDisplayNameChanged(string DisplayName); [EventType] public record CustomerEmailChanged(string Email); ``` Chronicle uses the command context to resolve the event source identity and event stream metadata before appending events. ## Event tags Attach structured named tags to every returned event using `[EventTag]`, `ICanProvideEventTags`, a returned `EventTags` value, or application-wide `ICanProvideCommandEventTags` providers. Command tags form a union with each wrapper’s `NamedTags`; the same name with different values keeps both values. See [Event tags](/arc/backend/csharp/chronicle/commands/event-tags/) for examples and merge rules. ## Event Source Id Resolution Chronicle resolves the event source id for commands using a small set of conventions. This value is stored in the command context and is required for event appending. `ICanProvideEventSourceId` takes precedence. Otherwise Arc selects the first property matching any of: `EventSourceId`, `EventSourceId` ancestry, or Chronicle `[Key]`. There is no typed-before-key precedence; use one unambiguous candidate. If none of these are present, Chronicle creates a new `EventSourceId` so the command still has a valid identity for event appends. The following declarations are identity fragments; add a public instance `Handle()` for a complete command. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Keys; [Command] public record OpenAccount(Guid AccountId, string OwnerName) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => AccountId.ToString(); } [Command] public record RenameAccount(EventSourceId AccountId, string NewName); [Command] public record CloseAccount([Key] Guid AccountId); ``` For command input identity, ordinary query binding (which does not use these conventions), and the later append override from a returned `EventSourceId`, see [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/) and [Returning EventSourceId](/arc/backend/csharp/chronicle/commands/returning-event-source-id/). ## Event Stream Metadata Chronicle supports additional metadata that can be attached to commands and used when appending events. This metadata tags the appended events with the specified stream identity, making them easier to query and react to. ### EventStreamId Use `[EventStreamId]` to assign a specific event stream id to a command, or implement `ICanProvideEventStreamId` to supply it dynamically. The declarations in this metadata section are attribute-focused fragments, not complete commands; supply `Handle()` in your application. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [Command] [EventStreamId("customer-profile")] public record UpdateCustomerProfile(EventSourceId CustomerId, string DisplayName, string Email); [Command] public record UpdateCustomerPreferences(EventSourceId CustomerId, string PreferenceKey, string PreferenceValue) : ICanProvideEventStreamId { public EventStreamId GetEventStreamId() => "customer-preferences"; } ``` If both a non-empty `[EventStreamId]` value and `ICanProvideEventStreamId` are used, Chronicle treats this as ambiguous and throws an `AmbiguousEventStreamId` exception. Choose one approach, or set the attribute value to `null` to defer to the interface. ### EventStreamType Use `[EventStreamType]` to categorize events under a named stream type. This is useful for grouping related streams, such as separating onboarding events from transaction events for the same event source. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.Events; [Command] [EventStreamType("Onboarding")] public record RegisterCustomer(EventSourceId CustomerId, string Email); ``` ### EventSourceType Use `[EventSourceType]` to tag events with a specific event source type when they are appended. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.Events; [Command] [EventSourceType("Customer")] public record RegisterCustomer(EventSourceId CustomerId, string Email); ``` These metadata attributes categorize and identify the appended events. Because the append carries them, the concurrency strategy configured on the event sequence resolves its expected tail with the same narrowing — so a routing-only tag already bounds the concurrency check, without any attribute opting in. Setting `concurrency: true` chooses which dimensions bound it explicitly; see [concurrency scoping](/arc/backend/csharp/chronicle/commands/concurrency/). ### Event source definitions The [event source definition guide](/arc/backend/csharp/chronicle/commands/event-source-definitions/) covers choosing the stream, stream ids, what the definition guards and migrating from strings; [scenarios](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/) and [how routing works](/arc/backend/csharp/chronicle/commands/event-source-definition-routing/) go further. Chronicle’s [event sources](/chronicle/event-sources/) page describes the definition itself, and its [Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/) pages explain the boundary the append is checked against. Use `[EventSource]` when a command always appends through a registered Chronicle event source definition. The definition supplies the event source type, validates the optional stream name, records the definition in `EventContext.EventSource`, and supplies its concurrency dimensions unless a legacy `concurrency: true` attribute explicitly selects dimensions. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.EventSources; [EventSource] [EventStream("transactions")] public class Account : IEventSource; [Command] [EventSource("transactions")] public record RecordTransaction(AccountId AccountId, decimal Amount) { public TransactionRecorded Handle() => new(Amount); } ``` A returned `EventForEventSourceId` can route one event differently from the command. The definition and the stream are one routing decision: * An event that sets only `EventStream` uses the command’s definition and that stream. * An event that sets `EventSource` uses that definition and its own `EventStream`; it inherits nothing from the command’s stream. * An event that sets the legacy `EventSourceType` or `EventStreamType` is appended by those strings, not through the command’s definition, because appending through the definition would silently discard them. Unset values inherit the command’s strings. On a definition-routed command, Arc refuses a wrapper-named stream that a known definition does not declare; a source-only override with an inherited stream remains valid. * `EventStreamId` and `Subject` on the event are independent of this and win over the command when set. The concurrency dimensions the definition declares apply only when no legacy attribute opts into concurrency with `concurrency: true`. A legacy attribute that does not opt in carries metadata only, so it does not displace the definition’s dimensions. Do not combine a definition declaration with contradictory `[EventSourceType]` or `[EventStreamType]` attributes. Arc rejects the command context rather than append an event whose string metadata disagrees with the definition. Existing string attributes remain supported for commands that do not use definitions. ## Events for Specific Event Sources Sometimes a single command needs to append events to multiple different event sources. The standard approach appends all events to the same event source resolved from the command context, which is fine for the common case. When you need finer control — for example, a fund transfer that debits one account and credits another — use `EventForEventSourceId`. `EventForEventSourceId` is a record that pairs an event with an explicit `EventSourceId`. Chronicle appends each event to its specified event source, independently of the event source id in the command context. Within an active [command transaction](/arc/backend/csharp/chronicle/commands/transactional-commands/), the appends across all the event sources are atomic — if any of them is rejected, none of them land. Without an active transaction, a returned collection still appends as one batch: a refusal appends nothing. That includes a collection that sends events for the same event source id to several routes: the batch carries a guard for each route, and a violation of any of them refuses the whole batch. Sending several guards for one event source id needs a Chronicle kernel 19.84 or later; against an older kernel the Chronicle client refuses the batch with `IncompatibleConcurrencyScopesForEventSource` before sending anything, so nothing is appended. Return a single `EventForEventSourceId` when only one cross-source event is needed: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; [Command] public record MigrateCustomerToNewId(EventSourceId OldCustomerId, EventSourceId NewCustomerId) { public EventForEventSourceId Handle() => new(NewCustomerId, new CustomerMigrated(OldCustomerId)); } [EventType] public record CustomerMigrated(EventSourceId OldCustomerId); ``` Return an `IEnumerable` to append events to several different event sources in one command: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; [Command] public record TransferFunds(EventSourceId FromAccountId, EventSourceId ToAccountId, decimal Amount) { public IEnumerable Handle() => [ new EventForEventSourceId(FromAccountId, new FundsDebited(Amount)), new EventForEventSourceId(ToAccountId, new FundsCredited(Amount)) ]; } [EventType] public record FundsDebited(decimal Amount); [EventType] public record FundsCredited(decimal Amount); ``` Arc enumerates and enrolls these wrappers in order, but that is not a guarantee of cross-source batch order. Chronicle’s ordinary unit-of-work staging groups events by source: `A1, B1, A2` can commit as `A1, A2, B1`. Use [`EventsWithConcurrencyScopes`](#events-with-exact-concurrency-scopes) when global batch order matters. Within the active transaction, the events still commit through one atomic append when the command succeeds. A constraint violation, concurrency conflict, or append error rejects the whole batch and becomes an ordinary failed `CommandResult`; no event from the returned batch lands. A wrapper can also carry when the fact happened and how to tag it. Set `Occurred` to record a historical occurrence time, for example when importing or backfilling, and `Tags` to add tags to that one event. Chronicle merges them with any static `[Tag]` on the event type: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; [Command] public record ImportCredit(EventSourceId AccountId, decimal Amount, DateTimeOffset CreditedAt) { public EventForEventSourceId Handle() => new(AccountId, new FundsCredited(Amount)) { Occurred = CreditedAt, Tags = ["imported"] }; } ``` Arc keeps both for a single wrapper, a collection, a mixed collection, and `EventsWithConcurrencyScopes`, whether the command appends immediately or through its transaction. Leave `Occurred` unset to use the append time. The wrapper’s routing properties (`EventStreamType`, `EventStreamId`, `EventSourceType`, and `Subject`) win over the command context when set, as described in [per-event routing](#per-event-routing); otherwise those come from the command context, as described in [event stream metadata](#event-stream-metadata). You can mix `EventForEventSourceId` values with regular events in a tuple return, letting some events use the command’s own event source while others target specific event sources: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; [Command] public record AcceptOrder(EventSourceId OrderId, EventSourceId CustomerId) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => OrderId; public (OrderAccepted, EventForEventSourceId) Handle() => ( new OrderAccepted(), new EventForEventSourceId(CustomerId, new CustomerOrderAccepted(OrderId)) ); } [EventType] public record OrderAccepted; [EventType] public record CustomerOrderAccepted(EventSourceId OrderId); ``` > `EventForEventSourceId` does not share one concurrency scope across targets — a scope carries a single stream’s expected tail, so it cannot be reused for another stream. The command’s concurrency declaration still applies: one scope is built per target event source, with that target’s own expected tail. Each append also uses the stream metadata from the command (stream id, stream type, event source type) while targeting the event source id you supply explicitly. > > When several wrappers target the same event source id, a guard that only follows from the event source definition is derived per event, from that event’s own stream and definition, and is never reused for another event. Events written through a definition reach the event sequence without a scope, which derives and validates the guards itself. Wrappers that override the definition with legacy routing and need different guards for one id keep every distinct guard. The unit of work, or the batch when no transaction is active, requires all of them, so a violation of any guard refuses everything. Several guards for one id need a Chronicle kernel 19.84 or later; an older kernel throws `IncompatibleConcurrencyScopesForEventSource` before anything is appended. Guards for the same stream and event types collapse to one. A mix of such legacy guards and a guard that follows from a definition for the same id is rejected with `IncompatibleConcurrencyScopesForEventSource` before anything is enrolled or appended, because Chronicle stops deriving a definition guard once it is handed a scope for the id. A guard you chose with `concurrency: true` keeps its existing behavior. Split the events into separate commands or return `EventsWithConcurrencyScopes` with an explicit scope when you need a different combination. ## Events with exact concurrency scopes The automatic strategy resolves a target’s expected tail after `Handle()` returns: during response processing for attribute-selected scopes, or during commit for the fallback scope. That is right for ordinary optimistic concurrency. When a command makes its decision from an exact revision it already read, return `EventsWithConcurrencyScopes` to carry that revision with the events instead of resolving a newer tail later. The response contains two values: * the `EventForEventSourceId` values, in append order; and * the exact concurrency scopes the decision depended on, keyed by labels you choose. ### Exact scopes replace derived scopes When `Handle()` returns `EventsWithConcurrencyScopes`, the returned scopes replace every scope Arc would otherwise derive for the command: the ones from `concurrency: true` on `[EventSourceType]`, `[EventStreamType]` and `[EventStreamId]`, and the ones from an event source definition’s concurrency dimensions. A `concurrency: true` flag on the command’s attributes is therefore dead and [ARCCHR0016](/arc/backend/csharp/chronicle/code-analysis/arcchr0016/) warns about it with a quick fix that removes it. The definition is not reported, because it stays the policy for every other writer. ### Per-event routing A routing value set on an `EventForEventSourceId` wins over the command context. This applies to every return that carries wrappers: a single wrapper, a collection, a mixed collection, a tuple such as `(FundsSettled, EventForEventSourceId)`, and `EventsWithConcurrencyScopes`. One event can override its event source type or stream while the others keep the command’s metadata. A plain, unwrapped event always uses the command context. A value counts as set when it differs from its default: `EventSourceType.Default`, `EventStreamType.All`, `EventStreamId.Default`, or a non-null `Subject`. A value left at its default falls back to the command context. Prefer typed overrides (`EventSource = typeof(Definition)` and `EventStream = "stream"`) when the target has an event source definition. Choosing another declared stream or another definition is supported; a different route is not itself a mistake. A stream-only typed override uses the command’s definition. Use `IEventRoutes` to resolve a definition route once and stamp returned events without repeating legacy strings: ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Chronicle.Streams; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSources; [EventSource] [EventStream("Transactions")] [EventStream("Adjustments")] public class AccountEventSource : IEventSource; [Command] [EventSource("Transactions")] public record AdjustAccount(EventSourceId AccountId, decimal Amount) { public EventForEventSourceId Handle(IEventRoutes routes) => routes.For("Adjustments").Route(AccountId, new BalanceAdjusted(Amount)); } [EventType] public record BalanceAdjusted(decimal Amount); ``` This event uses the `Adjustments` stream and retains `AccountEventSource` and its concurrency policy, even though the command declares `Transactions`. Legacy string overrides remain supported, but they append by strings and drop the definition from `EventContext.EventSource`; they also omit the selected definition’s concurrency policy. [ARCCHR0021](/arc/backend/csharp/chronicle/code-analysis/arcchr0021/) suggests typed routing when literal strings name a declared stream, and calls an override redundant when it repeats the command route. Its quick fix retains the definition, so review the concurrency change before applying it. [ARCCHR0020](/arc/backend/csharp/chronicle/code-analysis/arcchr0020/) warns when a literal wrapper route names a known definition and an undeclared stream on the wrapper itself. At runtime, a **definition-routed command** rejects a legacy wrapper route with `EventStreamDoesNotBelongToEventSource` when its resolved source matches a registered definition but its wrapper-named `EventStreamType` is undeclared. Arc checks each wrapper before enrolling or appending it, including wrappers in mixed collections, tuples, and `EventsWithConcurrencyScopes`. Collections and exact-scope responses are resolved before their first event is enrolled or appended. Computed strings get the same runtime check. Unknown source names, unset stream sentinels, source-only overrides with inherited streams, and commands without a definition keep their legacy behavior. A stream-only override is checked against the command’s definition. Valid legacy strings are not silently converted to typed routes. The framework resolves response handlers through their constructors taking `IEventSources`. Their obsolete legacy constructors remain available for manually constructed handlers, but do not validate per-event routing against the registry; migrate manual construction to the new constructors to get this check. An empty response with **no events and no concurrency scopes** is a successful no-op: Arc neither appends an empty batch nor enrolls one in the active command transaction. This is useful when an equivalent declaration already exists. If scopes are supplied, Arc still forwards them through the normal append or transaction path; an empty event list never silently discards a required concurrency check. A label can name an event target, but it does not have to. An independent label lets a command protect a broader fact — for example, the tail of all active-administrator events — while writing to member and invitation streams. Exact revisions that govern authorization or another invariant must never come from request input. Resolve the authoritative revision on the server while handling the command, and construct any independent scope label from a deterministic server-owned value. Otherwise, a caller could choose which version of the protected fact the command validates. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSequences.Concurrency; [Command] public record InviteFirstAdministrator( EventSourceId MemberId, EventSourceId InvitationId) : ICanProvideEventSourceId { static readonly EventSourceId AdministratorScope = "active-administrators"; public EventSourceId GetEventSourceId() => MemberId; // Intentional server-side read; no imperative append bypasses the return path. #pragma warning disable ARCCHR0007 public async Task Handle(IEventLog eventLog) #pragma warning restore ARCCHR0007 { var activeAdministratorEvent = typeof(AdministratorActivated).GetEventType(); var tail = await eventLog.GetTailSequenceNumber( filterEventTypes: [activeAdministratorEvent]); var expectedAdministratorRevision = tail == EventSequenceNumber.Unavailable ? EventSequenceNumber.BeforeFirst : tail; return new EventsWithConcurrencyScopes( [ new(MemberId, new MemberInvited()), new(InvitationId, new InvitationIssued(MemberId)) ], [ new( AdministratorScope, new ConcurrencyScope( expectedAdministratorRevision, EventTypes: [activeAdministratorEvent])) ]); } } [EventType] public record AdministratorActivated; [EventType] public record MemberInvited; [EventType] public record InvitationIssued(EventSourceId MemberId); ``` This is a concurrency-routing example, not a complete first-administrator business rule. It watches changes to `AdministratorActivated`, not invitation uniqueness, and does not reject an already-active administrator by itself. Authorize and validate that decision separately. When the matching history is empty, `BeforeFirst` expresses “no matching event may exist”; `Unavailable` is not a protected empty revision. Arc enrolls the ordered events and exact scopes together. If a matching activation event appears after the read, commit rejects the returned batch. A concurrent invitation without an activation is outside this scope. `ICanProvideEventSourceId` deliberately selects `MemberId` as the input-time command identity, including for any identity-bound dependencies. The returned wrappers still target their explicit member and invitation ids. This removes the two-property ambiguity reported by [ARCCHR0002](/arc/backend/csharp/chronicle/code-analysis/arcchr0002/); `EventsWithConcurrencyScopes` is not a recognized exemption in the current analyzer. The narrow ARCCHR0007 suppression documents an intentional read-only `IEventLog` dependency; the analyzer matches the parameter type, not whether a method writes. Use an exact scope only for a revision the command actually read. `ConcurrencyScope.NotSet` retains the event sequence’s configured strategy for an event-target label, while `ConcurrencyScope.None` deliberately disables checking for that label. An independent label must carry a concrete exact scope or `ConcurrencyScope.None` because there is no target from which Chronicle can infer a scope. # Fresh identifiers > Reject a generated append target that already has events, without rerunning the command handler. A generated identifier is meant to open a new event source, not accidentally add facts to an existing one. With Arc’s Chronicle integration, a [`[GeneratedValue]` Handle parameter](/arc/backend/csharp/commands/generated-values/) uses `GeneratedValueFreshness.Guarded` by default. When an Arc-owned append or a write through the command’s unit of work targets that value, Arc requires the **whole event source** to be empty before the command’s events are appended. ## What is guarded Arc compares each actual append destination with the validated values generated in the same invocation. It unwraps concepts and typed event-source identifiers. A Guid uses lowercase `D` format; strings compare exactly, with ordinal case sensitivity. A string that looks like a Guid remains a string. Other supported primitive identities use Chronicle’s invariant formatting, including round-trip dates and times. Arbitrary records’ display strings are not identifiers. The guard covers: * Returned events and event collections, including an identifier and events returned together in a tuple. * Single or multiple `EventForEventSourceId` wrappers, including collections mixed with plain events. * `EventsWithConcurrencyScopes`, preserving compatible explicit whole-source emptiness scopes. * Aggregates acquired with `IAggregateRootFactory.Get` using a guarded generated identifier, and their mutations. * Direct `IUnitOfWork.AddEvent`, `AddEvents` and `AddEventWithNamedTags` calls on the command’s unit of work. * `eventLog.Transactional.Append`, `AppendMany`, `AppendWithNamedTags` and `AppendManyWithNamedTags` on that unit. Values used only in a payload or response add no scope. For example, a generated API key returned to the caller does not guard the customer’s event source. If several generated parameters have the same value, one guarded capture is enough to guard that destination. This is a Chronicle integration policy. Arc.Core captures the policy but does not depend on Chronicle. ## Whole-source emptiness, not an append-time tail The guard is equivalent to: ```csharp new ConcurrencyScope(EventSequenceNumber.BeforeFirst, EventSourceId: id) ``` This expression uses `Cratis.Chronicle.Events` and `Cratis.Chronicle.EventSequences.Concurrency`; `id` is the actual append destination. It has no stream, source-type or event-type narrowing. Existing events in a different stream or of another type still occupy that identifier. Several events for one new source share the same immutable expectation: all can succeed in one transaction. Routing metadata, concurrency attributes and the normal concurrency strategy cannot replace it with a later tail or a narrowed expectation. Exact scopes supplied through `EventsWithConcurrencyScopes` or direct unit-of-work and transactional calls are checked for conflicts. A compatible explicit whole-source `BeforeFirst` scope merges with the guard, including scopes emitted by Stage. A different explicit intent, such as a narrowed scope, a tail revision or `ConcurrencyScope.None`, fails the command with `ConflictingConcurrencyScopes` before the batch is enrolled or appended. Without a transaction, Arc appends a returned collection as one batch, so a generated source’s freshness guard stays in the same atomic append as every other event of the command, whatever routes those events use and wherever the generated source’s events sit in the collection. An aggregate factory checks whole-source emptiness before constructing, rehydrating or activating the aggregate. That read is an early rejection, not the atomic guarantee: mutation still enrolls the whole-source `BeforeFirst` scope, so an intervening writer is rejected when the unit commits. ## Handle a collision An occupied generated destination produces a validation result with: | Field | Value | | --- | --- | | `Reason` | `ValidationResultReason.ConcurrencyViolation` | | `ReasonDetail` | `"GeneratedIdentifierCollision"` | Arc classifies this result using the generated guard’s provenance. An unrelated `BeforeFirst` failure does not acquire that detail. Arc’s collision and `ConflictingConcurrencyScopes` messages do not include the generated value. Arc also maps conflicts Chronicle detects against an already enrolled generated guard to `ConflictingConcurrencyScopes`, using typed scope properties and the command unit’s provenance rather than parsing messages. Unrelated enrollment conflicts retain Chronicle’s own redacted failure; they are not classified as generated-identifier conflicts. Arc never reruns `Handle()`. To retry a collision, submit a **new command invocation**, which generates a new value. Do not keep retrying a fixed scenario pin against an occupied source. This policy is not an idempotency protocol: after an uncertain commit, determine whether the first invocation committed before creating another source. ## Opt out deliberately `GeneratedValueAttribute.Freshness` is settable. `GeneratedValueFreshness` is in `Cratis.Arc.Commands`. For an existing generated parameter, the explicit opt-out is: ```csharp [GeneratedValue(Freshness = GeneratedValueFreshness.Unguarded)] ``` Import `Cratis.Arc.Commands.ModelBound` for the attribute and `Cratis.Arc.Commands` for the enum. `Unguarded` keeps generation, validation and capture, but leaves concurrency protection to your command’s own policy. Use it only when appending to an existing source is intentional; it is not a collision-retry strategy. The capture exposes the immutable policy through `GeneratedValue.Freshness`. Use high-entropy identifier strategies such as `GenerateValue.Uuid()` or `GenerateValue.UuidV7()`. Arc does not infer entropy from a concept’s name or enforce an entropy threshold. Client-minted and natural identifiers are not generated Handle captures: protect their uniqueness with a Chronicle [constraint](/chronicle/constraints/), rather than assuming this guard covers them. ## Direct writes through the command’s unit of work The direct and transactional APIs listed above receive the same whole-source guard and collision classification as returned events. This applies only to the command’s actual unit of work, not arbitrary new units or external stores. Returned, aggregate and direct events for the same generated source share one expectation; an occupied source rejects the whole pending unit, including events for other sources. Nested pipeline invocations share the outer command’s unit. When Arc composes returned-event or aggregate scopes, it considers guarded captures from every active invocation on that unit. A nested handler’s attribute-derived scope therefore cannot narrow an outer generated identifier’s whole-source guard. Truly caller-supplied scopes remain subject to the explicit conflict check. After successful enrollment, Chronicle retains the required scope even when that invocation’s contributor is disposed. A later unscoped write inherits the requirement. If Chronicle rejects an incompatible supplied scope before Arc’s contributor runs, Arc translates conflicts against that recorded generated guard to `ConflictingConcurrencyScopes`. Scope-only `AddEvents` entries preserve existing requirements but do not call contributors, create a new freshness guard or record generated provenance. An identifier captured by a completed nested invocation but never targeted has no enrolled requirement. Nested pipeline scopes leave commit and rollback to the outer command. Propagate a nested rejection so the outer command rolls back instead of committing earlier events. An explicit aggregate `Commit()` can still finalize the shared unit early; see [Nested commands and aggregates](/arc/backend/csharp/chronicle/commands/transactional-commands/#nested-commands-and-aggregates). Custom `IUnitOfWork` implementations must support Chronicle’s enrollment contributors. A handler declaring at least one guarded `[GeneratedValue]` parameter fails closed with Arc’s `FreshIdentifierGuardRequiresEnrollmentContributors` if its unit cannot register one. Commands without generated parameters, or with only `Unguarded` parameters, do not require this support. `IUnitOfWork.AddEvents` resolves scope requirements for all destinations before staging events in that call. A scope conflict stages none of that call’s events; a contributor notification failure occurs after staging and prevents the unit from committing. `Transactional.AppendMany` and `AppendManyWithNamedTags` stage per event instead: a later rejection may leave earlier events pending. Let the exception escape `Handle()` so Arc rolls back the whole unit. Catching the rejection and succeeding can commit earlier enrollments. ### Immediate appends remain outside the guard Non-transactional `eventLog.Append`, `AppendMany` and their named-tag variants inside `Handle()` bypass unit-of-work enrollment. They remain immediate, cannot be rolled back by the command and do **not** receive automatic freshness protection. Prefer returned events or `eventLog.Transactional` on the command’s unit. Under read protection `On`, an immediate append is refused with `ImmediateAppendWhileReadsAreProtected`. An immediate caller can supply an explicit whole-source `BeforeFirst` scope and inspect the append result, but that write has no automatic generated-target provenance and need not report `GeneratedIdentifierCollision`. ## Verify an occupied identifier in a spec Create a [command scenario](/arc/backend/csharp/testing/generated-values/), then pin the generated parameter by name. Guarded generated parameters automatically select the in-process log before seeding. `Given.ForEventSource(id).Events(...)` seeds the same event log the freshness guard checks. This excerpt uses `CreateSource`, `Identifier`, `Created` and `ResultShape` from the [fresh-identifier spec context](https://github.com/Cratis/Arc/blob/main/Source/DotNET/Chronicle.Specs/Commands/for_CommandScenario_with_fresh_identifiers/given/a_fresh_identifier_scenario.cs). Import `Cratis.Arc.Testing.Commands`, `Cratis.Arc.Chronicle.Testing.Commands`, `Cratis.Arc.Validation` and `Cratis.Specifications`: ```csharp await using var scenario = new CommandScenario(); var id = new Identifier(Guid.Parse("fb6dd43a-b161-44b1-9baa-7b2e4a071680")); scenario.PinGeneratedValue("id", id); scenario.Given.ForEventSource(id).Events(new Created("existing")); var tailBefore = await scenario.EventLog.GetTailSequenceNumber(); var command = new CreateSource(ResultShape.Wrapper); var result = await scenario.Execute(command); result.ValidationResults.Single().Reason.ShouldEqual(ValidationResultReason.ConcurrencyViolation); result.ValidationResults.Single().ReasonDetail.ShouldEqual("GeneratedIdentifierCollision"); (await scenario.EventLog.GetTailSequenceNumber()).ShouldEqual(tailBefore); command.Calls.ShouldEqual(1); ``` Seed an event for the same identifier, including a different stream if your command routes to a specific stream. Also exercise an empty source with two events through your chosen append API: both must append successfully under one guard. A fixed pin is reused across executions. A second create invocation with the same pin should collide rather than silently continue the first source. ## Supported Chronicle versions Direct-write freshness and redacted scope-conflict translation require Chronicle client **19.65.2 or later**. Deploy a Chronicle kernel **19.40.1 or later** for retry-time revalidation of expected-empty scopes; upgrading a client alone does not upgrade the kernel. Chronicle 19.59.2 and later also normalize equal `BeforeFirst` batch scopes. Use a current compatible kernel when several destinations or explicit scope labels share one batch. For stateful decisions over an existing source, see [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/) and [choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/). # Commands for Chronicle occurrences > Execute transactional commands for Chronicle occurrences without an HTTP request, using atomic effect receipts to avoid repeating recorded effects. A Chronicle occurrence is work the event store hands to your application when no one made a request: a schedule came due, the application started or stopped, a clock rule fired. An occurrence can be delivered more than once — a crash, a lost lease or a restart redelivers it. `IOccurrenceCommandExecutor` commits a transactional command’s events and receipt atomically. A later delivery with the same effect key returns the recorded outcome without running the handler. Concurrent attempts can still enter the handler before one wins the commit; external side effects need their own idempotency keys. It is part of `Cratis.Arc.Chronicle` and needs a Chronicle kernel that advertises the `effect-receipts` capability (client 19.81 and later). ## How it works The executor identifies each command by an **effect key**: the occurrence id, an effect name you choose, and the lease that is writing. When the command succeeds, Chronicle records an **effect receipt** in the same atomic commit as the command’s events. The receipt and the events either both exist or neither does, so a crash after the commit can never be followed by a second run. For each call the executor: 1. Opens the scope of the occurrence’s namespace and checks that the tenant mapping resolves to that same namespace. 2. Looks up an existing receipt for the key. If one exists, it returns the stored outcome without running anything. 3. Runs the command as the system with only the roles you pass, on a fresh correlation id, with an `Occurrence` entry on the causation chain. 4. Commits the command’s unit of work with the receipt enrolled, and returns the winning receipt. If another attempt recorded one first, you get that stored outcome and nothing of this attempt is kept. ## Execute a command for an occurrence ```csharp public class SendReminders(IOccurrenceCommandExecutor executor) : IOccurrenceHandler { public async Task Handle(ReminderDue due, OccurrenceContext occurrence, CancellationToken cancellationToken) { var result = await executor.Execute( new SendInvoiceReminder(due.InvoiceId), occurrence, "send-reminder", ["Scheduler"], cancellationToken); if (!result.IsSettled) { // Nothing was recorded. Throw so the occurrence is redelivered and the effect is attempted again. throw new ReminderAttemptFailed(due.InvoiceId); } } } ``` `ReminderDue`, `SendInvoiceReminder` and `ReminderAttemptFailed` are the application’s own types. The overload without roles runs as the system with no roles; name the roles the command’s authorization needs. Nothing is inherited from a caller. ## What the result tells you | Disposition | Meaning | What to do | | --- | --- | --- | | `Recorded` | This attempt wrote the receipt. The effect is settled. | Acknowledge the occurrence. | | `AlreadyRecorded` | A receipt already existed. `Outcome` and `Response` are the stored ones. | Acknowledge the occurrence. | | `Failed` | A later attempt can succeed. No receipt, nothing committed. | Let the occurrence be redelivered. | `Outcome` is `Appended`, `NoEvents` or `Refused`. `CommandResult` carries the command’s own result when this attempt ran it. ### What is terminal and what is retried | Command outcome | Receipt | Disposition | | --- | --- | --- | | Events committed | Written atomically with the events | `Recorded` (`Appended`) | | Succeeded without events | Written (`NoEvents`) | `Recorded` | | Validation failure, business refusal, constraint violation | Written (`Refused`) | `Recorded` — an operator reruns with a new effect key | | Concurrency conflict or a protected read that changed | None | `Failed` | | Handler exception or infrastructure failure | None | `Failed` | | Authorization failure | None | `Failed` | A refusal is terminal because retrying the same input would be refused again. To run the effect again after an operator has fixed the cause, use a new effect name. ## The stored response The receipt stores a minimal plaintext response: the command type name, the outcome, the number of events, and for a refusal the category and member or constraint names. It never serializes command property values or the handler’s response values. Validation messages are also excluded because they can echo personal data or secrets. Keep member and constraint names free of personal data. Command names and individual reasons are capped at 256 characters, with at most 16 reasons plus an omission marker. Truncated strings end in `[truncated]`. These limits keep the serialized response below 64 KiB even when JSON escapes every character. The same bounded refusal reasons are used in the receipt’s outcome. Because the receipt does not store the command’s response values, a personal-data annotation or an oversized handler response does not require up-front refusal of keyed execution. If you need the command’s own response on redelivery, query the state the events produced; it is not replayed from the receipt. ## What a command cannot do inside an occurrence One receipt covers one unit of work, and the receipt is written only by its final commit. The executor refuses these: * **Running inside another command.** `OccurrenceCommandCannotRunInsideCommand` is thrown. Call the executor from the occurrence handler, not from a command or reactor that already owns a unit of work. Commands the command returns or nests share the unit and are covered by the same key. * **Committing early.** An aggregate root `Commit()` throws `OccurrenceCommandCannotCommitEarly`. Even if the pipeline catches it in a `CommandResult`, the executor rethrows it to the occurrence handler; it is a programming error, not a retryable `Failed` result. * **Completing a stream.** Stream completion is applied outside the atomic commit. The executor surfaces `OccurrenceCommandCannotCompleteStreams` to the occurrence handler, also rethrowing it when the pipeline captured it. No receipt is written. * **Appending immediately.** Chronicle’s built-in event sequences refuse direct (non-transactional) appends before sending anything in the command’s async flow. The executor rethrows `ImmediateAppendRefused`, rolls back pending transactional events and writes no receipt. This is a programming error, not a retryable `Failed` result. Return events from the handler or append through the command’s unit of work using `.Transactional`; those events still commit atomically with the receipt. The refusal ends when the invocation exits, so later commands outside the occurrence can append immediately. Custom `IEventSequence` implementations must honor Chronicle’s append-admission contract; the executor retains post-commit detection on the command’s event log as a backstop, not prevention or coverage of other sequence instances. See [transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/). ## Fail closed without the kernel capability When the kernel does not advertise `effect-receipts`, or the capability is unknown, the executor throws Chronicle’s `EffectReceiptsNotSupported` before running the command. It never runs a command it cannot make retry-safe. ## Occurrence causation and time The causation chain of every event the command appends includes an `Occurrence` entry before the command’s entry. A root or process entry can come first. The occurrence entry carries `occurrenceId`, `triggerId`, `kind`, `recordedAt`, `dueAt` for scheduled occurrences, `attempt`, `epoch` and `effectKey`, followed by the usual [command causation](/arc/backend/csharp/chronicle/commands/causation/). Every attempt gets a fresh correlation id. The event’s `Occurred` time is the time it was appended, not the due time of the occurrence; read `dueAt` from the causation when you need it. ## Namespaces and tenants The occurrence’s namespace selects the tenant: the default namespace is the default tenant, any other namespace is a tenant of that name. The executor then verifies that the event store resolves to the same namespace and throws `OccurrenceCommandNamespaceMismatch` when your tenant mapping disagrees. An occurrence without a namespace runs in the default namespace. ## Testing Test an occurrence handler against the in-process kernel from `Cratis.Chronicle.Testing`: its capability switch makes the kernel advertise or hide `effect-receipts`, and it stores receipts so a second execution of the same effect key returns `AlreadyRecorded`. See [Testing with Chronicle](/arc/backend/csharp/testing/chronicle/#commands-for-occurrences). ## Things to know * The effect name must be stable across attempts and unique per effect inside one occurrence. Pass `occurrence.EffectKey(name).Value` to external systems that accept an idempotency key. * An effect needing more than one unit of work needs more than one effect name; each name has its own receipt. * Receipt replay carries only the minimal outcome, never a command’s response values. The receipt remains plaintext. * Event reactors that return commands are a different mechanism; see [command side effects](/arc/backend/csharp/chronicle/reactors/command-side-effects/). # Returning EventSourceId from a command > Hand the generated identity back to the caller when the command is what creates the entity. When a command creates a customer and chooses the identity in `Handle()`, return a `CustomerId` alongside the event. Derive it from `EventSourceId` so it names the domain entity and tells Chronicle where to append. The caller receives the same identity for its next query or command. ## Return EventSourceId together with the event When `Handle()` returns a tuple containing an event and an `EventSourceId` or `EventSourceId`-derived value, Chronicle uses the returned id for the automatic append. Use shared domain concepts for the identity and customer details: ```csharp using System; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Concepts; public record CustomerId(Guid Value) : EventSourceId(Value) { public static readonly CustomerId NotSet = new(Guid.Empty); public static CustomerId New() => new(Guid.NewGuid()); public static implicit operator CustomerId(Guid value) => new(value); } public record CustomerEmail(string Value) : ConceptAs(Value) { public static readonly CustomerEmail NotSet = new(string.Empty); public static implicit operator CustomerEmail(string value) => new(value); } public record CustomerDisplayName(string Value) : ConceptAs(Value) { public static readonly CustomerDisplayName NotSet = new(string.Empty); public static implicit operator CustomerDisplayName(string value) => new(value); } [Command] public record RegisterCustomer(CustomerEmail Email, CustomerDisplayName DisplayName) { public (CustomerId, CustomerRegistered) Handle() { var customerId = CustomerId.New(); return (customerId, new CustomerRegistered(Email, DisplayName)); } } /// /// Records a customer's registration with their email and display name. /// [EventType] public record CustomerRegistered(CustomerEmail Email, CustomerDisplayName DisplayName); ``` The tuple order does not matter. This alternative `Handle()` on the same command returns the event first: ```csharp public (CustomerRegistered, CustomerId) Handle() { var customerId = CustomerId.New(); return (new CustomerRegistered(Email, DisplayName), customerId); } ``` Chronicle recognizes the identity in either position; the event does not need to carry it in its payload. ## Use typed ids Reuse `CustomerId` in subsequent commands and read models so unrelated identities cannot be swapped accidentally. `CustomerEmail` and `CustomerDisplayName` likewise keep two different domain values distinct in C#. These concepts retain simple underlying wire values; add concept validators when their invariants should follow them through validation. Bare `EventSourceId` remains supported by the API, but `CustomerId : EventSourceId` gives that framework meaning a domain name. Return the domain type rather than converting it to a raw `Guid`. ## A raw Guid is only a response Do not use a raw Guid as the new event source id Do not return `(Guid, event)` when the `Guid` is meant to identify the new event source: ```csharp [Command] public record RegisterCustomer(CustomerEmail Email, CustomerDisplayName DisplayName) { // ARCCHR0010: the response and persisted event source receive different ids public (Guid, CustomerRegistered) Handle() => (Guid.NewGuid(), new CustomerRegistered(Email, DisplayName)); } ``` A raw `Guid` is an ordinary command response. It does not carry event-source semantics, even though `Guid` can be converted to `EventSourceId` when a command property is explicitly selected as the key. Because this command declares no key, Chronicle creates a fallback event source id before `Handle()` runs and appends `CustomerRegistered` under that fallback—not under the returned `Guid`. [ARCCHR0010](/arc/backend/csharp/chronicle/code-analysis/arcchr0010/) heuristically reports supported signatures and offers a bounded quick fix to `EventSourceId` when the edit compiles. Prefer a domain identity such as `CustomerId` when this value really identifies the event source. An unrelated confirmation Guid with a generated fallback event source is valid. Do not change persistence routing just to silence the warning; document that intent and suppress ARCCHR0010 narrowly as shown in its reference. ## Important behavior * Chronicle treats the returned `EventSourceId` as append metadata and as the command response value. * If the command record already had an event source id, the returned value wins for return-driven appends. It does not reload earlier dependencies, retarget an injected aggregate, or move already-enrolled events. * If you need to target several different event sources from one command, use [Events](/arc/backend/csharp/chronicle/commands/events/#events-for-specific-event-sources) and return `EventForEventSourceId` values instead. # Stream decisions > Read a routed stream and return events guarded at the tail used by the decision. When a command decides from events in one stream, inject `IStreamReads` in `Provide()` and return `decision.Append(...)` from `Handle()`. The returned events carry the read route and an exact concurrency scope. A competing append to that stream after the read rejects the command with a concurrency validation failure, including when both commands attempt the first append. Use a stream decision when the facts are raw events in one routed stream. For a rule over the folded state of one or more event sources, take the read model as a parameter under [read protection](/arc/backend/csharp/chronicle/consistency/getting-started/) `On`; for uniqueness, use a constraint. The cost is the whole stream crossing the wire on every command, so keep streams short. [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) compares the mechanisms, and its [scenarios](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/#a-period-approval) show this one next to the others. A stream decision is Chronicle’s [Dynamic Consistency Boundary](/chronicle/dynamic-consistency-boundary/) over one stream: Arc captures the tail before it reads, and the append fails if a matching event arrived after it. [Choosing a mechanism](/chronicle/dynamic-consistency-boundary/choosing/) and [Chronicle’s scenarios](/chronicle/dynamic-consistency-boundary/scenarios/) show the same decision from Chronicle’s side. This guide assumes Chronicle is [configured for the application](/arc/backend/csharp/chronicle/). `IStreamReads` and `IEventRoutes` resolve by Arc’s service conventions; no additional registration is needed. ## Read the command’s stream Declare the route on the command. `ForCommand()` uses the event source id and route already resolved by the pipeline, including a [template stream id](/arc/backend/csharp/chronicle/commands/concurrency/#template-event-stream-ids). ```csharp using Cratis.Arc.Chronicle.Streams; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; [Command] [EventSourceType("reports")] [EventStreamType("approval")] [EventStreamId("{Owner}:{Month}")] public record ApproveReport(EventSourceId ReportId, string Owner, string Month) { public Task Provide(IStreamReads reads) => reads.ForCommand(); public EventsWithConcurrencyScopes Handle(StreamDecision decision) => decision.Has() ? decision.Append([]) : decision.Append(new ReportApproved()); } [EventType] public record ReportApproved; ``` The empty append represents an **identical retry**: approval was already recorded and there is nothing new to record. It returns no events and no scopes and succeeds without checking whether the stream changed again. Do not use this branch to hide a missing prerequisite or a rejected action, and do not return a nullable event. Reject invalid requests through validation. `StreamDecision.Events` contains `AppendedEvent` values; `EventsOf()` selects payloads and `Has()` checks for a type. The reader captures `Tail` **before** fetching events and discards events beyond that tail. `IsEmpty` means the tail was unavailable, and the scope uses `ExpectingNoMatchingEvent()` to protect the first append. Source type, stream type and stream id filters are identical for the read and guard. If you need folded state on the command’s own route, use a [per-stream read model](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/) declared with `[EventStream(stream)]` as a plain parameter. Under `On` its read is guarded; `Off` and `Measure` read the same stream without a guard. A `DecisionRead` parameter of that declared model also uses the command route. For another stream or source, use the command-aware [`IDecisionReads` stream overload](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/#read-a-projection-within-one-stream) in `Provide()`. Under `On`, or in a `[ProtectedDecision]` command, it enrolls in the same transaction. Models without a per-stream declaration keep their key-based injection. ## Reuse a definition-backed route For a command declaring [`[EventSource(stream)]`](/arc/backend/csharp/chronicle/commands/event-source-definitions/), `ForCommand()` also retains the event source definition and its selected stream. If you need a route outside the current command, inject `IEventRoutes` and resolve it from the same discovered registry. This excerpt assumes your application declares an `IEventSource` named `ReportSource` with an `approval` stream: ```csharp EventRoute route = routes.For("approval").WithStreamId("owner:2026-10"); StreamDecision decision = await reads.Stream(reportId, route); EventsWithConcurrencyScopes response = decision.Append(new ReportApproved()); ``` Here `routes` is `IEventRoutes`, `reads` is `IStreamReads` and `reportId` is an `EventSourceId`. `EventRoute.For(definition, stream)` is the pure alternative when you already have an `EventSourceDefinition`. An unknown stream raises `EventRoutingContradictsEventSource`, just as a command declaration does. `CommandContext.GetEventRoute()` exposes the pipeline’s resolved route. For ordinary returned events, `route.Route(id, fact)` and `route.Route(id, facts)` produce wrappers stamped with all routing dimensions and, when present, the definition. A route built with `new EventRoute(sourceType, streamType, streamId)` keeps string-based routing available. ## Guard every stream id of a type Call `reads.StreamType(id, route)` when a decision depends on all streams of one type for the event source. Its scope omits the stream id dimension, so an append to a sibling stream id conflicts too. `decision.Append(...)` still writes to the **specific stream id** in `route`. Both read modes require a specified event source id, a specific stream type and a specific append stream id. `EventSourceId.Unspecified`, `EventStreamType.All`, and `EventStreamId.Default` or `NotSet` raise `StreamDecisionRequiresSpecificRoute`. ## Combine a stream decision with a read A stream decision is an exact scope for its stream. Under read protection `On`, a command that also reads a model of the **same event source** needs a scope that covers what the read watches. A stream is narrower than a whole-source read, so the command is refused with `ExplicitScopeDoesNotCoverReads`, and [ARCCHR0036](/arc/backend/csharp/chronicle/code-analysis/arcchr0036/) reports it at build time. Read a different source, or read the stream through `IDecisionReads` with the same stream dimensions. ## Keep one exact scope per source Chronicle’s `EventsWithConcurrencyScopes` labels scopes by event source id: one scope per id per command. It cannot carry separate guards for two stream ids of the same source. Use the stream-type-wide variant when that boundary matches the decision, or split the command. Do not combine two independent stream decisions for the same id and assume both tails are guarded. Exact scopes replace automatic concurrency capture. Remove `concurrency: true` from string routing attributes on a command returning `EventsWithConcurrencyScopes`; retain the routing values. A shared event source definition’s concurrency policy stays on the definition, and the exact returned scope intentionally takes precedence. See [concurrency](/arc/backend/csharp/chronicle/commands/concurrency/#exact-scopes-replace-automatic-capture). ## Seed and interfere on the same route The command’s stream-decision dependency automatically selects one in-process event log before seeding. This excerpt uses `ApproveReport` and `ReportApproved` from the first example: ```csharp using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Arc.Testing.Commands; await using var scenario = new CommandScenario(); var id = EventSourceId.New(); var route = new EventRoute("reports", "approval", "owner:2026-10"); scenario.Given.ForEventSource(id).OnRoute(route).Events(new ReportApproved()); scenario.AppendConcurrently(id, route, new ReportApproved()); ``` For a definition, use `scenario.EventRoutes.For("approval").WithStreamId(...)`. Routed seeding in the legacy read-model mode is refused with `RoutedEventSeedingRequiresDecisionReads` because that mode cannot retain stream routing. [Scenarios for each stream shape](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/#decide-against-the-stream) show a stream decision on a definition-routed command, and [how routing works](/arc/backend/csharp/chronicle/commands/event-source-definition-routing/#the-concurrency-scope-arc-passes) explains the scope it passes. A nonempty decision append conflicts with a matching queued competitor; a competitor on another stream id succeeds for `Stream()` and conflicts for `StreamType()`. Execute a command that produces a new fact to test that guard: the identical-retry branch deliberately returns no scope. # Setting Subject on commands > Tell Chronicle which compliance identity a command writes under, so PII is encrypted under the right key. Use `Subject` on a Chronicle command when the compliance identity for returned events is different from the event source id. Arc passes the resolved subject as metadata for return-driven appends. This is separate from read-model decryption: `Release(instance)` resolves its subject from the instance, not from the command’s subject. If you do not provide a command subject, Chronicle consults event-level subject metadata before the final event-source-id fallback. See the [subject resolution reference](/arc/backend/csharp/chronicle/compliance/subject/) for precedence and dependency timing. Aggregate `Apply()` does not forward the command-context subject in the current integration. Its events use Chronicle’s event-level subject resolution and fallback. Setting or returning a subject on the command does not retag those events. See the [aggregate limitation](/arc/backend/csharp/chronicle/compliance/subject/#aggregate-apply-limitation). The examples are routing fragments with application domain types, not complete PII encryption examples. Annotate event properties or shared concepts independently when they contain personal data. ## Set Subject on the command When the subject is already part of the command, put it on the record itself. Implement `ICanProvideSubject` when you want the subject to be computed: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record PlaceOrder(EventSourceId OrderId, CustomerId CustomerId, decimal Amount) : ICanProvideEventSourceId, ICanProvideSubject { public EventSourceId GetEventSourceId() => OrderId; public Subject GetSubject() => new(CustomerId.Value.ToString()); public OrderPlaced Handle() => new(CustomerId, Amount); } /// /// Records the customer and amount of a placed order. /// [EventType] public record OrderPlaced(CustomerId CustomerId, decimal Amount); ``` `GetEventSourceId()` selects the order explicitly, even if the application’s `CustomerId` also derives from an event-source id. The event keeps that foreign customer reference; the order’s own id is in event context. Use a `Subject` property directly when the command already has the final compliance identity: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record ImportCustomer(EventSourceId CustomerId, Subject Subject, string Email) { public CustomerImported Handle() => new(Email); } /// /// Records the email address of an imported customer. /// [EventType] public record CustomerImported(string Email); ``` Use `[Subject]` when the source value is not already a `Subject`: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record RegisterCustomer(EventSourceId CustomerId, [Subject] Guid PersonId, string Email) { public CustomerRegistered Handle() => new(Email); } /// /// Records the email address supplied when a customer registered. /// [EventType] public record CustomerRegistered(string Email); ``` Chronicle converts the `[Subject]` value to `Subject` by calling `ToString()`. ## Override Subject from Handle() Return `Subject` in the tuple from `Handle()` when the subject is decided inside the handler. A returned subject overrides any subject that was resolved from the command itself. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record RegisterDependent(EventSourceId HouseholdId, Guid PersonId, string Name) { public (DependentRegistered, Subject) Handle() => ( new DependentRegistered(PersonId, Name), new Subject(PersonId.ToString()) ); } /// /// Records the person registered as a household dependent. /// [EventType] public record DependentRegistered(Guid PersonId, string Name); ``` The `Subject` value is append metadata. Chronicle does not treat it as the command response. A returned subject arrives after validators, `Provide()`, and handler dependencies have been resolved; it cannot change their earlier release or retag already-enrolled events. ## When to use this page This page focuses on setting subject values on command appends. For the compliance background and how subject affects PII encryption and decryption, see [Subject](/arc/backend/csharp/chronicle/compliance/subject/). # Transactional commands > Atomic enrollment, immediate appends, nested commands, and explicit aggregate commit boundaries in Arc's optional Chronicle integration. With `Cratis.Arc.Chronicle` registered, a model-bound command executed through Arc’s command pipeline owns a Chronicle transaction. Returned events and explicit `eventLog.Transactional` appends enroll in it. Pending enrolled events commit atomically when the command succeeds; a failed command rolls them back. This is a **Chronicle event transaction**, not a transaction over arbitrary databases, HTTP calls, or external services. The integration uses Arc’s [command execution scope](/arc/backend/csharp/commands/command-execution-scopes/) extension point. Standalone Arc does not supply this event-store guarantee. Events and external operations have different recovery boundaries Return events for durable domain facts. For immediate external side effects chosen by the same command, prefer [command operations](/arc/backend/csharp/commands/operations/) instead of direct service calls or a hand-written rollback stack. Their optional compensation runs only when the observed commit boundary permits it; external work is not part of Chronicle’s atomic event transaction. Use a reactor or durable workflow when work must follow committed facts reliably. ## Choosing an append style | Style | Boundary | Outcome | | --- | --- | --- | | Return events from `Handle()` | Enroll in the command transaction | Final `CommandResult` | | Return `EventsWithConcurrencyScopes` | Enroll ordered cross-source events with exact revision expectations | Final `CommandResult` | | `eventLog.Transactional.Append(...)` | Enroll; no append has occurred yet | `Task`, not a per-append result | | `eventLog.Append(...)` or `eventStore.EventLog.Append(...)` | Immediate and final | Real `AppendResult`; observed failures can also fail the command | | Aggregate `Apply(...)` | Enroll aggregate events | Await mutation; see aggregate failure handling below | | Aggregate `Commit()` | Commit the shared unit of work immediately | `AggregateRootCommitResult` | Returning events is the recommended path. This complete command/type example assumes the integration is configured: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Concepts; public record OnboardingId(Guid Value) : EventSourceId(Value) { public static readonly OnboardingId NotSet = new(Guid.Empty); public static OnboardingId New() => new(Guid.NewGuid()); public static implicit operator OnboardingId(Guid value) => new(value); } public record InvitationId(Guid Value) : EventSourceId(Value) { public static readonly InvitationId NotSet = new(Guid.Empty); public static InvitationId New() => new(Guid.NewGuid()); public static implicit operator InvitationId(Guid value) => new(value); } public record OrganizationNumber(string Value) : ConceptAs(Value) { public static readonly OrganizationNumber NotSet = new(string.Empty); public static implicit operator OrganizationNumber(string value) => new(value); } [Command] public record StartOnboarding(OnboardingId OnboardingId, InvitationId InvitationId, OrganizationNumber OrganizationNumber) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => OnboardingId; public IEnumerable Handle() => [ new(OnboardingId, new OnboardingStarted(OrganizationNumber)), new(InvitationId, new AdminInvited(OnboardingId)) ]; } /// /// Records that onboarding started for an organization. /// [EventType] public record OnboardingStarted(OrganizationNumber OrganizationNumber); /// /// Records an administrator invitation associated with an onboarding. /// [EventType] public record AdminInvited(OnboardingId OnboardingId); ``` Keep each concept in its own application file. The named ids prevent swapping an invitation and an onboarding. Because both derive from `EventSourceId`, `GetEventSourceId()` explicitly selects the command’s identity; each returned wrapper still selects its own append target. `AdminInvited` keeps the onboarding id as a foreign reference, not the invitation’s own stream id. If Chronicle rejects one event, none of this returned batch lands. A constraint must actually be configured to enforce uniqueness; the event/type declarations above do not create one. ## Explicit transactional and immediate appends These are method-body fragments, not complete commands. `eventLog` is an `IEventLog`; `sensorId` is an `EventSourceId`; the event records are application types. ```csharp await eventLog.Transactional.Append(sensorId, new ReadingRegistered(reading)); ``` This awaits enrollment, not commit. Use it only with an active transaction. Outside a command or another explicitly established unit of work, it throws. On the command’s unit of work, direct `AddEvent`, `AddEvents`, `AddEventWithNamedTags` and all four `Transactional` append variants enforce [generated-identifier freshness](/arc/backend/csharp/chronicle/commands/fresh-identifiers/) just like returned events. Nested invocations share that protection and an occupied generated target rejects the whole pending unit at commit. This integration requires Chronicle client 19.65.2 or later and contributor support from custom units. `AddEvents` preflights the whole call; transactional many-event methods stage per event, so let a later enrollment rejection escape the handler to roll back earlier pending events. ```csharp var result = await eventLog.Append(sensorId, new ImportAttempted()); ``` A successful immediate append survives any subsequent failure. Under read protection `On` an immediate append is refused with `ImmediateAppendWhileReadsAreProtected`; stage the event with `eventLog.Transactional.Append(...)` or return it. The command scope observes failed append operations on its event log and merges attributable failures into the command result; it cannot un-append earlier successes. This is not a guarantee for arbitrary other stores or event sequences. Immediate appends also bypass the automatic generated-identifier guard; return events or use the command’s transactional unit for that protection. [ARCCHR0007](/arc/backend/csharp/chronicle/code-analysis/arcchr0007/) warns on `IEventLog` parameters in `Handle()`, even for reads or deliberate transactional use. Prefer returned events. For an intentional advanced use, narrowly suppress that warning with a documented reason; suppression does not change the append boundary. ## Completion ``` ChronicleChronicle unit of workHandle()Arc command pipelineChronicleChronicle unit of workHandle()Arc command pipelinealt[successful command and transaction still pending][failed command and transaction still pending]BeginInvokeResult and returned eventsEnroll returned eventsCommitAtomic appendAppend outcomeRollback ``` Commit violations are translated to the command result, including validation details where available. An exception or validation failure rolls back **pending** enrollment, not an already completed transaction or immediate append. The scope applies to HTTP model-bound commands, direct `ICommandPipeline` execution, reactor commands, and `CommandScenario`. A controller action does not participate merely because it is an HTTP request; explicitly executing through the pipeline does. Chronicle’s request-level unit-of-work middleware is a separate boundary: an Arc command starts its own transaction rather than joining that request-level unit of work. ## Nested commands and aggregates Command operations use a narrower execution profile The nesting and explicit-commit behavior below describes ordinary Chronicle commands. The initial [command operation contract](/arc/backend/csharp/commands/operations/reference/#supported-scope-profile) supports flat commands and deferred completion; nested operation-bearing commands and explicit aggregate commits inside an operation boundary are rejected. Existing event-only command behavior remains separate. A nested pipeline command joins the outer command’s active transaction. Only the outer owner completes it. A successful nested result reflects enrollment, not final persistence; commit-time violations arrive at outer completion. The outer command must inspect/propagate a nested failure rather than treating a failed nested call as success. Aggregate mutations share this unit of work. Explicit aggregate `Commit()` finalizes **all events enrolled so far**, not just one aggregate. The command scope leaves a completed transaction alone. Do not enroll further events or do fallible work afterward while expecting rollback to undo that commit. There is a separate failure-propagation limitation: `AggregateRoot.Commit()` checks the aggregate’s accumulated `Failed(...)` results; automatic command completion commits the unit of work directly and does not collect those lists. Removing explicit commit from an aggregate that uses `Failed(...)` can change behavior. Return its commit result and end the handler at that boundary, or propagate rejection through the command’s result before automatic completion. Committing one aggregate does not validate every other aggregate’s private failures. See [Defining an aggregate root](/arc/backend/csharp/chronicle/aggregates/defining-an-aggregate-root/). ## Occurrence commands A command run for a Chronicle occurrence through `IOccurrenceCommandExecutor` also writes an effect receipt in its final commit. That commit must be the only one: early aggregate commits, stream completion and immediate appends are refused for it. See [Commands for occurrences](/arc/backend/csharp/chronicle/commands/occurrence-commands/). ## Things to know * **Reads do not see pending enrollment.** An event-log read observes persisted events, not the pending batch. It can see successful immediate appends. * **Keep the transaction in its owner lifetime.** Do not use `Transactional` from fire-and-forget work or continuations that outlive the command. Enrollment against a completed unit of work can be silently discarded; use an awaited command or a reactor for follow-up work. * **One batch is not a distributed transaction.** Keep a unit of work within one event store, namespace, and event sequence. The ordered-batch API rejects mixing event sequences; do not infer a cross-sequence guarantee from legacy imperative enrollment. Independently configured databases and external calls need their own consistency design. * **Several returned reactor commands are separate transactions.** Earlier successes remain if a later command fails. Model one command when its events must commit together. * **Concurrency is a separate decision.** Atomic append does not bind a decision to the revision it read. See [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/), including empty-history expectations, and [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) for the mechanisms that bind a decision to the state it read. Under read protection `On`, an aggregate and a plain read of the same event source are refused, and a read or an append after the aggregate’s `Commit()` is refused; read first and return `Commit()` last. Outside `On`, do not mix an aggregate `Commit()` with `[ProtectedDecision]` reads: once a decision read is enrolled, the aggregate’s commit throws because only the command’s owner may commit the unit of work. For test patterns that assert no partial returned batch and inspect command failures, see [Testing with Chronicle](/arc/backend/csharp/testing/chronicle/). For an event plus external operation, [test compensation after a real constraint rejection](/arc/backend/csharp/testing/command-operations-with-chronicle/). # Compliance > PII is encrypted at the event log boundary and decrypted transparently on the way out, so commands and queries stay unaware of it. Event sourcing and the right to erasure look like a contradiction. Events are immutable facts — that is the whole point — and yet someone can demand their personal data be deleted. You cannot rewrite history, but you must be able to make personal data unreadable. Chronicle resolves this by encrypting `[PII]`-annotated properties at the event log boundary with a **per-subject key**. Crypto-shredding destroys that key, making values actually encrypted under it unreadable without another retained key copy. The events remain. This does not erase historical plaintext, causation metadata, exports, or independently retained copies; those need their own privacy and retention controls. Arc integrates compliance release into read-model interception so supported query paths do not need handwritten decryption or key-fetching code. Your application still chooses the right subject and authorizes access to the data. ## What that looks like in practice Two things have to happen, and both are automatic: * **On the way in**, choose the compliance *subject* — the identity whose key encrypts the data. Arc can supply command metadata for return-driven events; aggregate `Apply()` does not forward it. Absent an explicit append subject, Chronicle consults event subject metadata and finally the event source id. See [Subject](/arc/backend/csharp/chronicle/compliance/subject/). * **On the way out**, read-model interception requests release before supported query responses and streaming emissions reach the client. Observable HTTP snapshots currently bypass interception; do not assume every transport releases PII identically. See [PII](/arc/backend/csharp/chronicle/compliance/pii/) and the [interception coverage table](/arc/backend/csharp/queries/read-model-interception/). Command dependencies have their own release path. Materialized state may already be released by Chronicle; Arc requests an additional release for every existing command-side instance, whether or not the command declares a subject, and does not pass a command subject to the release call. Passive reducer state can be constructed in-process. See [Subject](/arc/backend/csharp/chronicle/compliance/subject/) before assuming these paths are equivalent. Erasure and release failures are different Chronicle releases genuinely encrypted values with an erased key as empty strings. Service error responses can retain the original instance, while transport or deserialization failures can throw. Test the resulting model and response shape; there is no unconditional non-breaking guarantee. ## Topics | Topic | Description | | --- | --- | | [PII](/arc/backend/csharp/chronicle/compliance/pii/) | Automatic decryption of PII-annotated properties on read models before they are served to clients. | | [Subject](/arc/backend/csharp/chronicle/compliance/subject/) | Setting the compliance subject on a command so Chronicle keys PII encryption to the correct identity. | ## How Chronicle compliance works underneath Chronicle encrypts properties annotated with `[PII]` at the event log boundary using a per-subject encryption key. The *subject* is the compliance identity — typically a person rather than an aggregate. When events are projected into read models, encrypted values are stored as-is. Before a read model reaches a client, those values are decrypted with the subject’s key. For the full explanation of annotating types, managing encryption keys, and honoring erasure requests, see the [Chronicle compliance guide](/chronicle/compliance/). # PII > Release encrypted read-model values through Arc's interception pipeline, with explicit subject, transport, and erasure boundaries. When a query serves encrypted personal data, the caller needs usable values—not encryption plumbing in every query method. Arc’s [read-model interception](/arc/backend/csharp/queries/read-model-interception/) integrates Chronicle’s release operation into supported query responses and WebSocket/SSE emissions. Observable HTTP snapshots currently bypass interception, including requests using `waitForFirstResult=true`. Choose and authorize each exposed response path deliberately; consult the interception coverage table rather than assuming a streaming guarantee also covers snapshots. For the full Chronicle-level guide on annotating types, identifying subjects, and honoring erasure requests, see [Chronicle compliance](/chronicle/compliance/). ## How It Works Chronicle encrypts `[PII]` properties at the event log boundary under the subject’s encryption key. On intercepted paths, Arc calls `Release()` on `IReadModels`, keeping decryption out of individual query methods. Release is not authorization, and it cannot restore an erased key. Given a read model with a PII-annotated property: ```csharp public record CustomerProfile( [Subject] CustomerId CustomerId, string CompanyName, [PII] string ContactEmail); ``` This is a model fragment; import `Cratis.Chronicle` and `Cratis.Chronicle.Compliance.GDPR` and supply the application’s `CustomerId` concept. Query paths using Arc’s read-model interception attempt release automatically. The instance’s subject must match the encryption subject, and release is not an authorization check. ## Behavior on Failure Distinguish erasure from an operational failure: * With no compliance metadata, the client returns the original instance without a release request. With no resolvable subject, it logs and returns the instance. * The server passes through values it does not recognize as encrypted. This is not retroactive encryption of historical plaintext. * For a genuinely encrypted value whose key was erased, the server releases an **empty string**, not the original ciphertext. * An error returned by the compliance service is logged and the client retains the original instance. * Transport, schema generation, decryption, or deserialization can still throw. In particular, test how empty released strings behave with your non-string concepts and converters. Model the absence of erased values deliberately. Test response shapes and exception handling through the client and server together; successful compilation alone does not exercise release. See [Subject](/arc/backend/csharp/chronicle/compliance/subject/) for materialized and passive dependency paths. ## Audit exclusion is a different boundary Command `[PII]` and `[NotAudited]` annotations can exclude command values from [causation](/arc/backend/csharp/chronicle/commands/causation/). A command annotation does not automatically annotate a newly constructed event. To encrypt persisted personal data, annotate the event’s property/type or a shared concept used by that event. Neither attribute is a substitute for access control or removing secrets from nested audit payloads. # Subject > Set the compliance subject on a command so Chronicle encrypts PII under the right identity. The `Subject` is Chronicle’s compliance identity — the value used to key per-subject material such as PII encryption keys. When you append an event that contains `[PII]`-annotated properties, Chronicle encrypts those properties under the subject’s key. For return-driven appends, selecting the correct subject on the command supplies the encryption identity for the returned events. When no append subject is supplied, Chronicle first resolves event-level `[Subject]` metadata and ultimately falls back to the `EventSourceId`. For returned events, set a command subject when their compliance identity must differ from that event-level resolution or fallback — for example, when an *order* event’s PII belongs to the *customer*. ## Resolution Order Before dependencies are loaded, `ICanProvideSubject` supplies the subject if implemented. Otherwise Arc looks for a property typed as `Subject` or marked `[Subject]` from `Cratis.Chronicle`, then a matching constructor-parameter declaration. Typed and attributed properties share a predicate: there is no typed-before-attributed priority. Declare one unambiguous subject. After `Handle()` returns, a returned `Subject` overrides append metadata for return-driven events. That later value cannot change earlier dependency resolution. Neither stage infers the subject from the authenticated user. ## Aggregate Apply limitation Aggregate `Apply()` does not forward the command-context subject in the current integration. Its events use Chronicle’s event-level subject resolution and fallback. Setting or returning a subject on the command does not retag those events. See [defining an aggregate root](/arc/backend/csharp/chronicle/aggregates/defining-an-aggregate-root/) for the separate aggregate append path. The return-driven examples below are command/type fragments using the application’s `CustomerId` concept; they illustrate subject routing, not a complete PII model. Each explicitly selects the order’s event-source id so a foreign customer reference cannot become the append target. The final attribute example assumes `CustomerId : ConceptAs`, a compliance identity rather than an event-source identity. ## Returning Subject from Handle The simplest approach when the subject is computed inside the handler: ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record PlaceOrder(EventSourceId OrderId, CustomerId CustomerId, decimal Amount) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => OrderId; public (OrderPlaced, Subject) Handle() => (new OrderPlaced(CustomerId, Amount), new Subject(CustomerId.Value.ToString())); } /// /// Records the customer and amount of a placed order. /// [EventType] public record OrderPlaced(CustomerId CustomerId, decimal Amount); ``` Arc detects the `Subject` in the tuple response and passes it to `Append` automatically. The `Subject` is treated as append metadata, so it does not become the command response even when you also return another response value in the same tuple. ## ICanProvideSubject Interface Use `ICanProvideSubject` when the subject is derived from command properties and you want an explicit, discoverable contract: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Chronicle.Commands; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record PlaceOrder(EventSourceId OrderId, CustomerId CustomerId, decimal Amount) : ICanProvideEventSourceId, ICanProvideSubject { public EventSourceId GetEventSourceId() => OrderId; public Subject GetSubject() => new(CustomerId.Value.ToString()); public OrderPlaced Handle() => new(CustomerId, Amount); } ``` ## \[Subject] Attribute on a Property When a command property directly represents the compliance identity, mark it with `[Subject]` from `Cratis.Chronicle`. Arc reads the property value and converts it to a `Subject`: ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle; using Cratis.Chronicle.Events; [Command] public record PlaceOrder(EventSourceId OrderId, [Subject] CustomerId CustomerId, decimal Amount) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => OrderId; public OrderPlaced Handle() => new(CustomerId, Amount); } ``` If the property is already of type `Subject`, Arc uses it directly even without `[Subject]`. Any other `[Subject]`-annotated type is converted via its `ToString()` representation. ## Relationship to PII Decryption Append metadata and read-model release have different sources of identity: * Materialized reads can already be released by the server using stored subject metadata. * Arc’s query interceptor calls `Release(instance)`. The Chronicle client resolves the release subject from the read-model instance, not from the query caller. * For command dependencies, Arc calls the non-generic `GetInstanceById(Type, key)`. It makes an additional `Release(instance)` call whenever the instance exists, whether or not the command declares a subject. It does **not** pass a command subject to `Release`; Chronicle resolves the subject from the read-model instance. This distinction matters for passive reducers, which can construct state in-process rather than receiving an already-released materialized document. Do not assume every dependency path decrypts identically, or that returning a subject from `Handle()` can repair a dependency already loaded. Model the read model’s subject explicitly and test materialized and passive paths separately. See [PII release behavior](/arc/backend/csharp/chronicle/compliance/pii/). The release subject must identify the key used at encryption time. Setting a subject is not authorization: protect query access independently. # Choosing how a command's rule holds > Decide whether a command's rule needs read protection, a constraint, a stream decision, an aggregate or an explicit concurrency scope, and what each one guards. Use this page when a command’s outcome depends on state: a stock level, a unique value, a capacity, a document that someone else may have edited. It tells you which mechanism makes the rule hold when two commands run at the same time, and which ones only look as if they do. ## The short answer When two requests read the same state, both decide it is fine, and both write, a plain read cannot tell them apart. Arc’s **read protection** fixes that for the state a command reads. With read protection **On**, a read-model parameter on a command is read fresh and guarded. If what the command read changes before its events are committed, nothing is written and the caller gets a `400` with the reason `concurrencyViolation`. | You want | Do this | | --- | --- | | A rule over stored state to hold | Set `On`, take the read model as a parameter, put the rule in a `CommandValidator`. See [Getting started](/arc/backend/csharp/chronicle/consistency/getting-started/) | | To try it on an existing application first | `Measure`, then `On`. See [Adopting read protection](/arc/backend/csharp/chronicle/consistency/adopting/) | | A value to be unique, or something to happen once | A Chronicle constraint | | A rule over one routed stream of raw events | A stream decision | | Rich behaviour on one entity | An aggregate root | | The caller supplies the revision it saw | An explicit scope | | A rare violation to be acceptable | The default append, and a reactor that repairs | Under **Off**, which is the default, nothing changes: an injected read model is a plain read, it returns a snapshot, and it guards nothing. The next major version of Arc makes `On` the default. See [The default changes in the next major version](#the-default-changes-in-the-next-major-version). ## Three concepts | Concept | What it is | Where you set it | | --- | --- | --- | | Mode: `Off`, `Measure` or `On` | Whether read-model parameters are guarded, only measured, or left alone | `` in the project, or `[ReadProtection(...)]` on a command | | `[Unprotected("why")]` | The read is deliberately advisory and guards nothing | A parameter, a read-model type, a `Handle()` or `Provide()` method body, or a command | | `[LookUpBy(nameof(Property))]` | Which command property selects the instance a parameter reads | A parameter | Read protection has two layers, and the names are close. **Read protection in Chronicle** is the client API that refuses unguarded reads inside one unit of work. See [Read protection in Chronicle](/chronicle/read-models/read-protection/). **The Arc mode** is the switch that turns that API on for your commands and resolves your parameters through it. This section is about the mode. ## The modes | Mode | Plain `T` parameter | Other reads (`IReadModels`, raw event log) | Immediate appends | Use it when | | --- | --- | --- | --- | --- | | `Off` | Plain read | Allowed | Allowed | The default. Nothing changes | | `Measure` | Plain read. Arc records what `On` would do | Allowed | Allowed | You adopt it on an existing application | | `On` | Read fresh and guarded | Refused unless `[Unprotected]` | Refused | New code, and migrated code | ## Decision matrix by rule shape | Rule shape | Use | Alternatives | Never rely on | Too much | | --- | --- | --- | --- | --- | | Unique value (email, user name) | Constraint: `[Unique]` on an event property | The value as the event source id, with an expected-empty scope (Chronicle used directly) | A validator that checks “does it exist” first | Read protection only for uniqueness | | Created once (one library card per member) | `[Unique]` on the event class | An aggregate: a new aggregate expects an empty history | An `Exists` check in a validator | A read over the whole history | | Rule over one key’s history, stock or count on one key | A read model parameter under `On`, the rule in a validator | An aggregate | A validator over a plain read under `Off` | A constraint: it cannot count | | Rule spanning two keys (course capacity and student limit) | Two read-model parameters, one event per key in one batch | Explicit scopes in the same batch (Chronicle used directly) | Two aggregates, each guarding only its own source | One model that folds both keys: it is refused | | Editing conflict | Explicit scope carrying the revision the client saw | A read model parameter when the edit does not depend on what the client saw | `concurrency: true` alone: it reads the tail after `Handle()` | A read model, which folds state to detect what a revision number already tells you | | One stream, for example approvals per period | Per-stream read model or a stream decision | A read-model parameter over the whole source | Reading the stream with an injected read model under `Off` | A whole-source read when only one period matters | | Rich behaviour (many invariants) | Aggregate root | Several validators over one model | Ignoring the result of `Commit()` | A read model for each invariant | | A rare violation is acceptable | Default append and a reactor that repairs | `concurrency: true` to narrow the default check | Assuming the repair runs before harm is done | Any guard | | UX hint (“no copies left”) | A query, or `[Unprotected("why")]` on the read | None needed | Treating the hint as the rule | A guarded read | | External data (a price, a tax rate) | `Provide()` fetches it. The read guards your own state | None | Assuming the external value is guarded | Guarding state you do not decide from | | Hot key with a long history | Key by period, or use a constraint | An aggregate (it reads the key’s handled events on every command) | A guarded read on a key that never stops growing | A flat model over years of events | “Never rely on” means the mechanism can fail silently: the command succeeds and the rule is broken. ## Choose a stream or source model | Rule lives in… | Use | | --- | --- | | Folded state of one stream on the command’s own route | [Per-stream model](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/) | | The entity’s whole history, such as a balance | Whole-source model. Combining it with a per-stream model gives a whole-source guard | | “Has fact X happened in this short stream?”, without a model | Stream decision | | Rich behaviour across the whole entity | Aggregate, without reads of the same source | | Another month or another source | `IDecisionReads` stream overload in `Provide()` | | Uniqueness across sources | Constraint | ## Decision flowchart ``` yes no no, only the shape of the input yes yes no yes no yes no yes no Is the rule that a value or an event happens once? Constraint Does the rule need state to decide? Validator without state, or data annotations Is a rare violation acceptable and repairable? Default append and a reactor that repairs Does the caller supply the revision it saw? Explicit scope: EventsWithConcurrencyScopes Is it rich behaviour on one entity? Aggregate root Do the facts live in one routed stream? Per-stream read model or stream decision Read-model parameter under On, rule in a validator ``` ## What each mechanism guards | Mechanism | Guards the rule? | Boundary captured | Cost of one command | | --- | --- | --- | --- | | Constraint | Uniqueness and create-once only. It cannot count or sum | None needed. The kernel checks every append | An index check inside the append | | Read-model parameter under `On` | Yes, for the event types the model folds, on the key it reads | Before the read | A fresh fold of the key’s history for each read | | Stream decision | Yes, for any event in the routed stream | Before reading the stream | The whole stream crosses the wire | | Aggregate root | Yes, for the event types it handles | When the aggregate is resolved | The handled events cross the wire. No snapshots | | Explicit scope | As strong as where you captured the revision | Wherever your code captured it | One filtered tail query at commit | | Default append | No. It checks only the window after `Handle()` | At commit, after `Handle()` | One tail query per event source | | Validator, or read model under `Off` | No. It is a hint | None | One lookup | | Reading the database directly | No. It bypasses Chronicle and can lag | None | One query | | Reactor repair | No. It is a later correcting event | None | An extra event and its observer | A guard detects any matching event appended through the kernel, whoever wrote it. It protects the decisions that carry it. A writer that decides without a guard can still break the rule, so a rule holds fully only when every writer that decides on it is guarded, or when it is a constraint. ## Where event source definitions fit An [event source definition](/arc/backend/csharp/chronicle/commands/event-source-definitions/) names the event source and stream a command appends to. With `[EventSource(stream)]` it also gives the append a typed concurrency boundary. That boundary is the default append check, so a definition alone is not a row in the matrix. It is the routing a [stream decision](/arc/backend/csharp/chronicle/commands/stream-decisions/) reads from, and the place a completed stream is declared. ## Dynamic Consistency Boundary The Dynamic Consistency Boundary (DCB) is an approach to event-sourced consistency: a command reads the events it needs, remembers the position of the last event it knew, and appends only if no matching event has arrived since. See [dcb.events](https://dcb.events/) for the specification and [Dynamic Consistency Boundary in Chronicle](/chronicle/dynamic-consistency-boundary/) for how Chronicle maps it. Read protection is how an Arc command uses that idea without writing it by hand. Each guarded read becomes a condition on the append: the event types the model folds, on the key it reads, after the position captured before the read. [How guards are derived](/arc/backend/csharp/chronicle/consistency/how-it-works/) shows the guard of each example. What DCB can do that Chronicle cannot: * Match events by arbitrary tags. Chronicle stores tags, but a guard narrows by event source id, event source type, stream type, stream id and event types. * Give one event several tags, so a fact about two entities is a single event. A Chronicle event has one event source id, so such a fact is written as one event per source, in one atomic append. * Keep several separate conditions on one source. Chronicle merges them into one wider condition, which is safe and can cause extra conflicts. * Fold a decision model across entities or by property. Joins, children and by-property keys are refused. See [Choosing how a rule holds in Chronicle](/chronicle/dynamic-consistency-boundary/choosing/) for the same decision from Chronicle’s side. ## When not to use read protection Choose something else when: * **The rule is uniqueness.** A constraint is cheaper and covers every writer. * **A hint is enough.** Mark the read `[Unprotected("why")]`. Do not pay for a fold to display a message. * **The state is too big or too hot.** A key with a long history, or a model read by every command, makes each command fold again. * **The model cannot be guarded.** Joins, children, by-property keys, filtered reducers and open-ended event types are refused. Use a flat, event-source-keyed model, an aggregate or a stream decision. * **The state is not in the event log.** External data, another database or another event sequence is outside the guard. ### Why protection is opt-in Read protection is chosen per project or per command, and `Off` is the default today. * A protected command fails closed. It refuses a read it cannot guard, an immediate append, and a read outside a parameter. Making that the default would turn commands that work today into errors. * Existing code reads plain models in validators and handlers in many places. Moving them is a change to the rule, not a flag. * A guarded read costs more than a plain one, and a team should accept that where the rule needs it. [Adopting read protection](/arc/backend/csharp/chronicle/consistency/adopting/) is the path that keeps existing commands working while you move. ### The default changes in the next major version The next major version of Arc turns read protection `On` by default. You can still turn it off where a command or a read must stay plain: * for one command, with `[ReadProtection(ReadProtectionMode.Off)]` or `[Unprotected("why")]` on the command; * for one read, with `[Unprotected("why")]` on the parameter, the read-model type, or, for reads in its body, the `Handle()` or `Provide()` method; * for a project, with `Off`. To keep today’s behaviour through the upgrade, set `` explicitly in each project now. To prepare for `On`, run `Measure` and fix what it reports. The change is tracked in [Cratis/Arc#3255](https://github.com/Cratis/Arc/issues/3255). ## Costs A guarded read is a fresh fold of the key’s history. Chronicle does not use the materialized state, because that state can lag and has no boundary. The cost grows with the number of events of the model’s types for that key, on every read, and identical reads inside one command share one fold. | Mechanism | Grows with | Note | | --- | --- | --- | | Materialized plain read | Nothing | Eventually consistent | | Read-model parameter under `On` | The key’s history of the model’s types | Two tail reads and a fold for each read, and a retry if events arrive during the fold | | Stream decision | The whole stream | No type narrowing | | Aggregate | The key’s handled events, on every command | No snapshots | | Constraint | Nothing | Checked inside the append | | `Measure` | Nothing on the request path beyond one awaited tail read | Scans run in the background on a sample | No benchmark for read protection is published yet, so this page gives the shape of the cost and no figures. Keep guarded models flat and keys short-lived: a key per period, per order or per booking, not one key for the life of the company. ## Where to go next * [Getting started](/arc/backend/csharp/chronicle/consistency/getting-started/): turn `On` and write your first protected command. * [Adopting read protection](/arc/backend/csharp/chronicle/consistency/adopting/): `Off`, `Measure`, `On`. * [Reads](/arc/backend/csharp/chronicle/consistency/reads/): which reads are protected, advisory or refused. * [Validators](/arc/backend/csharp/chronicle/consistency/validators/): the place for “may this command proceed” rules. * [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/): every error with its fix. * [How it works](/arc/backend/csharp/chronicle/consistency/how-it-works/): how guards are derived and merged. * [Scenarios](/arc/backend/csharp/chronicle/consistency/scenarios/): the same rule shapes written step by step. * [Pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/) and [FAQ](/arc/backend/csharp/chronicle/consistency/faq/). # Adopting read protection > Move an existing application from Off to On through Measure, read the Measure report, fix what it shows, and lower the mode at run time when you must. Use this page when you have commands that work today and want read protection without breaking them. New code can start on [Getting started](/arc/backend/csharp/chronicle/consistency/getting-started/) with `On`. ## The path | Step | Mode | What you do | Behaviour change | | --- | --- | --- | --- | | 1 | `Off` | Update the packages. Nothing else | None | | 2 | `Measure` | Set `Measure`. Run the application and your tests | None for callers. Arc records what `On` would do | | 3 | `Measure` | Read the report. Fix each refused read, or annotate it `[Unprotected("why")]` | The fixes are ordinary code changes | | 4 | `On`, per project | Switch one project at a time. Run its tests | Reads are guarded. Refused reads fail the command | You can mix modes. A command-level `[ReadProtection(ReadProtectionMode.Off)]` keeps one command off while its project is `On`, and `[ReadProtection(ReadProtectionMode.On)]` turns one command on in an `Off` project. A command-level `[Unprotected]` means `Off` for that command. The next major version of Arc makes `On` the default for projects that do not set ``. A project that sets the property keeps its mode through the upgrade. See [The default changes in the next major version](/arc/backend/csharp/chronicle/consistency/#the-default-changes-in-the-next-major-version). ## What Measure does Under `Measure`, reads, lookups, scopes, results and status codes stay as they are. Arc looks at each command and records what `On` would do. It never changes the outcome, and a failure inside the measurement never fails the command. | What | When | Cost | | --- | --- | --- | | Classifies each read-model parameter and each other read | On the request path, locally | None beyond reflection | | Reads the event log’s tail once at the start of the command | On the request path | One awaited call, with a timeout | | Scans for events that appeared during the command and match what it read | In the background, on a sample of commands | Background reads on the shared connection | Measure is best-effort. A command whose tail read timed out, or whose scan was dropped, is reported as `measurement_unavailable` and counts as no evidence either way. The report is a set of hypotheses, not an enforced guard. Measure settings are runtime configuration, in the same `ReadProtection` section as the [run-time cap](#cap-the-mode-at-run-time): | Key | Default | Meaning | | --- | --- | --- | | `MeasureSampleRate` | `0.1` | Fraction of commands whose reads are scanned in the background. Between 0 and 1 | | `MeasureTailTimeout` | `250` | Milliseconds to wait for the tail read at the start of a command. Between 1 and 5000 | | `MeasureScanTimeout` | `5000` | Milliseconds a background scan may take. Between 1 and 5000 | | `MeasureScanLimit` | `500` | The most events one background scan looks at. Between 1 and 500 | ## Reading the report Measure reports through three channels. | Channel | What you get | | --- | --- | | Metric | Counter `cratis.arc.read_protection.events` on the meter `Cratis.Arc.ReadProtection`, with the tags `command`, `category`, `model` and `reason` | | Trace | A span event named `read-protection.plan` on the current activity, with the same tags | | Log | A Warning once per command and category for `refused_read`, `immediate_append` and `scope_refusal`, and a periodic Information summary | Start with the counter, grouped by `category` and `command`. | Category | What it means | What to do | | --- | --- | --- | | `protected_read` | The read would be guarded | Nothing | | `refused_read` | The read would be refused. `reason` names the cause or the API | See the next table | | `advisory_read` | The read is declared `[Unprotected]`. `reason` is your text | Check the reason still holds | | `unknown_dependency` | A service or other dependency Arc cannot see | Nothing, but it is outside the guard. It is never counted as protected | | `redirect` | The `On` lookup rule would select a different key than the one the legacy path used | Add `[LookUpBy(nameof(Property))]` to say which key you mean | | `immediate_append` | The command appended through an `IEventLog` and `On` would refuse it | Return the event from `Handle()`, or use `IEventLog.Transactional` | | `displacement` | An explicit scope or stream decision sits beside a read of the same source | The two must be reconciled under `On` | | `scope_refusal` | `On` would refuse the command: its explicit scope is narrower than the read, captured after it, or incomplete | Widen the scope, or remove it | | `coarsened` | Reads of the same source would drop stream dimensions; `reason=stream_dims_dropped` | A whole-source read beside a stream read, or reads of different streams, chooses a whole-source guard | | `nested_concurrent`, `nested_early_completion` | A nested command ran in parallel with a sibling, or tried to commit the outer unit | Run nested commands one after another and commit in the outer command | | `self_write` | One of the command’s own events matches what it read | Informational. Own events are excluded from `matching_events_since_start` | | `matching_events_since_start` | An event written by someone else matched the read between its start and the scan. `On` might have returned `400` | A real race. This is the data that shows where protection matters | | `conflict_today` | An append of this command already failed | Informational | | `measurement_unavailable` | A measurement could not be taken. `reason` says why | Raise the timeouts, or ignore it | | `mode_lowered` | The run-time cap lowered the declared mode | See [Cap the mode at run time](#cap-the-mode-at-run-time) | Measure does not currently emit `routing_replaced` or `routing_kept_separately`: its observed scopes do not retain the distinction between explicit guards, default routing and declared routing, including scopes derived inside Chronicle at commit. It also cannot report declared-routing coarsening from those observations. It does not emit `nested_overlap`: append notifications arrive after commit, not when an outer command stages an event before a nested read. Absence of these categories is not evidence that the cases are safe under `On`. ### Fixing refused reads The `reason` of a `refused_read` is the Chronicle refusal. [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/) has the full table. The common ones: | `reason` or API | Fix | | --- | --- | | `Reducer` | Read a projection of the same events, or use an unfiltered reducer without constructor dependencies | | `ReducerDependencies`, `ReducerFilters` | Remove the reducer’s constructor dependencies or its event filter | | `Join`, `Hierarchy` | Read the parts as separate models | | `FromEventProperty`, `NotEventSourceKeyed` | Append a fact to the entity the model is keyed by, or use a constraint | | `GenerationDeliveryNotSupported` | Use Compatibility delivery for the projection | | `KeyConversion` | Give the model a string or `Guid` `Id` | | `StreamKeyRequiresStreamScope` | Read the stream-keyed model with `IDecisionReads.Get` and an explicit stream in `Provide()` | | `IReadModels`, `IEventStore`, `IEventSequence` | Take the model as a parameter, or mark the method `[Unprotected("why")]` | | `IMongoCollection` | Inject the read model instead of its collection | | `DetachedReadWhileReadsAreProtected` | Use `IDecisionReads.Get` in `Provide()`. A detached read is refused | Build-time diagnostics mark many of the same sites: [ARCCHR0024](/arc/backend/csharp/chronicle/code-analysis/arcchr0024/), [ARCCHR0026](/arc/backend/csharp/chronicle/code-analysis/arcchr0026/), [ARCCHR0028](/arc/backend/csharp/chronicle/code-analysis/arcchr0028/). Raise them to errors in the projects you move. ### How noisy On is `On` is quiet for commands over source-keyed projections. It is noisier for: | Situation | Protected option | Honest opt-out | Roll out | | --- | --- | --- | --- | | Reducer-backed model | An unfiltered reducer without constructor dependencies is admitted. Otherwise re-express it as a projection | `[Unprotected("reference data; staleness tolerated")]` on the model type | Keep the project in `Measure` until it is done | | Joined or enriched model | Separate source-keyed models with `[LookUpBy]` | `[Unprotected("copied as of read; may be stale")]` on the parameter | Keep the command `Off` | | List or search | A constraint | `[Unprotected]` on `Provide()` | | | Service that reads Chronicle | A parameter, or `IDecisionReads` in `Provide()` | `[Unprotected]` on `Handle()` | | ## Switching to On Switch one project at a time. 1. Set `On` for the project. 2. Build. Fix [ARCCHR0024](/arc/backend/csharp/chronicle/code-analysis/arcchr0024/), [ARCCHR0028](/arc/backend/csharp/chronicle/code-analysis/arcchr0028/) and [ARCCHR0033](/arc/backend/csharp/chronicle/code-analysis/arcchr0033/) warnings. [ARCCHR0011](/arc/backend/csharp/chronicle/code-analysis/arcchr0011/) no longer applies under `On`. 3. Run the project’s tests. Command scenarios automatically select the in-process log from the command’s read-protection mode and dependencies; see [Test the race](/arc/backend/csharp/chronicle/consistency/scenarios/testing/). 4. Exercise every command once. Refusals happen when a command runs, not at startup. Under `On`, discoverable validators with constructor dependencies must be registered by convention, which makes them transient. A validator registered by hand as scoped, singleton, a factory or an instance is refused. See [Validators](/arc/backend/csharp/chronicle/consistency/validators/). ### Upgrade note: missing protected models Under `On`, a missing required protected read-model parameter now returns the ordinary validation reason `rule` (`ValidationResultReason.Rule`) instead of `dependencyUnavailable`, with the message `{ReadModel} '{key}' does not exist.`. HTTP status remains `400`. Update clients or specs that distinguish these reasons. Invalid protected keys produce the same rejection before a read is acquired, including for nullable parameters; nullable parameters still receive `null` for a valid missing key. `Off`, `Measure` and advisory reads keep their previous behavior. See [Missing models and invalid keys](/arc/backend/csharp/chronicle/consistency/reads/#missing-models-and-invalid-keys). ## Cap the mode at run time Operations can lower the mode without a build. The cap only lowers. It never raises a mode that was not compiled in. ```json { "ReadProtection": { "MaximumMode": "Measure" } } ``` | `MaximumMode` | Effect | | --- | --- | | `On` (or missing, or invalid) | Declared modes apply | | `Measure` | Commands declared `On` run as `Measure` | | `Off` | Commands declared `On` or `Measure` run as `Off` | At startup, Arc logs a Warning for each command whose declared mode is lowered. Measure reports a `mode_lowered` observation with the declared and effective modes. Use the cap to back out of an incident while you fix the code, and remove it afterwards: while it lowers the mode, reads run with the lower mode’s behavior instead of `On`’s guards. ## Where to go next * [Reads](/arc/backend/csharp/chronicle/consistency/reads/): what each read becomes under `On`. * [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/): every error with its fix. * [Pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/): what still fails silently. # Use decision tokens in validators > Opt into fresh, provider-bound construction of validators for ProtectedDecision commands on untrimmed JIT runtimes. Use this opt-in when a `[ProtectedDecision]` command outside read protection `On` needs a discoverable validator that takes `DecisionRead` or the command-aware `IDecisionReads`. Prefer [plain read-model parameters under On](/arc/backend/csharp/chronicle/consistency/validators/) if you do not need explicit tokens. This capability supports **untrimmed JIT runtimes only**. Arc refuses it when dynamic code is unavailable. Trimmed JIT is unsupported even if dynamic code is available; provider certification does not preserve constructor metadata or make an application NativeAOT-safe. Existing parameterless-validator and `On` behavior is unchanged. ## Opt in at the host Keep your existing Arc and Chronicle configuration. Install Arc’s provider factory **after** `AddCratisArc`, which configures the default provider. This excerpt assumes `builder` is your `WebApplicationBuilder` and you have already configured Arc with Chronicle: ```csharp using Cratis.Arc.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; builder.Host.UseServiceProviderFactory(new ProtectedDecisionServiceProviderFactory(new ServiceProviderOptions { ValidateScopes = builder.Environment.IsDevelopment(), ValidateOnBuild = false })); ``` Pass DI validation options explicitly: without options, this factory sets both `ValidateScopes` and `ValidateOnBuild` to `false`. It does not inherit the host’s environment defaults or earlier `UseDefaultServiceProvider` configuration. The example retains Development-time scope validation and Arc’s disabled eager validation. The same factory works with `IHostBuilder.UseServiceProviderFactory` or `IHostApplicationBuilder.ConfigureContainer`. It must be the host’s actual Microsoft DI factory, not a wrapper around another container. Do not build a second root inside a running host. For an independently owned service collection, call `services.BuildProtectedDecisionServiceProvider(options)` after all registrations. Dispose the returned root and execute through a child scope, or use the pipeline’s providerless overload, which creates its own scope. Executing directly against the root is refused for this capability. ASP.NET request scopes, explicitly supplied native scopes and scopes created through an injected native `IServiceScopeFactory` use the same proof. A forwarding provider, a custom container, a disposed scope or a collection populated by copying descriptors does not acquire the original root’s evidence. `CommandScenario` continues to build its ordinary provider by default. ## Configure rules without an arbitrary factory Let convention discovery register the validator. Its only public constructor may take recognized decision reads and recursively constructor-bound transient collaborators. Using the domain types from [Validators and read protection](/arc/backend/csharp/chronicle/consistency/validators/), mark `BorrowBook` with `[ProtectedDecision]` (`Cratis.Arc.Chronicle.ReadModels`) and replace its plain-read validator with: ```csharp using Cratis.Arc.Commands; using Cratis.Chronicle.ReadModels; using FluentValidation; public class BorrowBookValidator : CommandValidator { public BorrowBookValidator(DecisionRead stock) => RuleFor(command => command.Isbn) .Must(_ => stock.Instance is { Available: > 0 }) .WithMessage("No copies of this book are available."); } ``` If you need to add rules at registration time, use `AddProtectedDecisionValidator(configure)` rather than a DI factory. For that token-taking validator: ```csharp using Cratis.Arc.DependencyInjection; using FluentValidation; builder.Services.AddProtectedDecisionValidator(validator => validator.RuleFor(command => command.Member) .NotNull() .WithMessage("A member is required.")); ``` Arc constructs a fresh validator and its inspected collaborators for each validation invocation, runs the callback once on that exact instance, then records its rule and component identities. A callback-added denial remains a denial; Arc never substitutes an undecorated validator. A thrown callback fails the command. On ordinary commands, the registration still runs the callback using ordinary constructor resolution, with DI owning the returned validator. This registration removes at most one Arc convention binding. It does not remove application bindings: a second explicit registration makes the protected construction plan ineligible. The callback and validator constructor are trusted application code, not a sandbox. ## Know what is refused Arc checks the complete constructor dependency plan before constructing its validators or collaborators. It accepts a discovered unregistered validator, a single Arc convention transient binding, or a single Arc-owned configuration registration. Each type needs exactly one public constructor. | Dependency or registration | Result | | --- | --- | | Framework-issued `DecisionRead` or the framework command-aware `IDecisionReads` | Accepted after invocation checks | | Constructor-bound transient collaborator with an entirely inspectable graph | Constructed fresh; never taken from a DI cache | | Scoped or singleton validator or collaborator | Refused | | Application validator type binding, ordinary factory or prebuilt instance | Refused; no fallback that drops its rules | | Factory or instance collaborator, cycles, ambiguous constructors, keyed bindings or open generic construction | Refused | | Provider, scope factory, deferred factory, optional constructor parameter or collection dependency | Refused, including `IEnumerable` and token collections | | Plain Chronicle read model, raw `IReadModels`, `IEventStore` or `IEventLog` | Refused; use a token, or move to `On` | | Replaced or duplicated token binding | Refused | The effective `IDecisionReads` binding must be the framework’s scoped command-aware binding. Every earlier binding must also have a framework-recorded origin; standard `WithChronicle` composition may retain superseded raw readers. Any unrecorded type, factory, instance or copied binding anywhere in that list refuses this construction path. Collections are not allowed to resolve those superseded readers. Register Chronicle through Arc’s `WithChronicle`: client registrations made directly by the application outside that call have no recorded origin and are refused by this new construction path. Application reader registrations inside either `WithChronicle` configuration callback also remain untrusted. A closed framework token factory may coexist with Arc’s origin-recorded open token convention binding, which is never activated; an application-added open binding is still refused. Registration evidence is frozen when each root is built. Mutating the original collection afterward cannot add or remove rules in that root. Every new-path validator is bound to its executing scope and command invocation, and its rule/component identities are rechecked before invocation. Changing those identities or reusing an instance across scopes or invocations refuses. Captures, static reads, condition changes and nested-child mutations remain [outside the certificate](/arc/backend/csharp/chronicle/consistency/validators/#what-arc-does-not-check). Arc releases owned validators and collaborators in reverse construction order after model validation, including rejection, exceptions and cancellation. Read leaves stay DI-owned. Cleanup failures fail the command before commit; a caller’s allowed-severity override cannot turn a construction or cleanup refusal into success. `Validate` remains advisory: it uses its own fresh invocation, does not append and does not promise that a subsequent `Execute` will commit. For callback-added business denials to block under `Off`, declare the appropriate [blocking severity](/arc/backend/csharp/commands/validation-severity-filtering/). # Read protection FAQ > Short answers about read protection, its modes, validators, lookups, costs and testing, and about the other mechanisms that make a command's rule hold. Use this page for a short answer. Each one links to the page that explains it. ## Choosing ### Does reading a read model in a validator or in `Handle()` make my rule hold? Under `Off`, no. An injected read model is a plain read: a snapshot that guards nothing. Under `On`, yes: the parameter is read fresh and guarded, so the rule holds against concurrent commands for the event types the model folds. See [Getting started](/arc/backend/csharp/chronicle/consistency/getting-started/). ### How do I make a unique value hold? A constraint. The kernel enforces it for every writer. See [Unique and once](/arc/backend/csharp/chronicle/consistency/scenarios/unique-and-once/). ### How do I enforce a count, a stock level or a capacity? A read-model parameter under `On`, with the rule in a `CommandValidator`. See [One entity](/arc/backend/csharp/chronicle/consistency/scenarios/single-entity/). ### How do I enforce a rule that spans two keys? Take both read models as parameters, choose each key with `[LookUpBy]` where needed, and return one event for each entity in one batch. See [Two entities](/arc/backend/csharp/chronicle/consistency/scenarios/two-entities/). ### How do I handle two people editing the same document? An explicit scope carrying the revision the client saw. See [Editing conflict](/arc/backend/csharp/chronicle/consistency/scenarios/editing-conflict/). ### Should I use an aggregate or a read model parameter? An aggregate fits one entity with many rules and transitions. A read model with a validator fits a rule over state that is cheap to project, and a rule over several keys. An aggregate and a plain read of the same source in one command are refused. See [Rich aggregate](/arc/backend/csharp/chronicle/consistency/scenarios/aggregate/). ### When is a rare violation acceptable? When the cost of the violation is lower than the cost of the guard and a reactor can repair it. See [Rules that need no guard](/arc/backend/csharp/chronicle/consistency/scenarios/no-guard/). ### When should I use a per-stream model? | Rule lives in… | Use | | --- | --- | | Folded state of one stream on the command’s own route | [Per-stream model](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/) | | The entity’s whole history, such as a balance | Whole-source model. Combining it with a per-stream model gives a whole-source guard | | “Has fact X happened in this short stream?”, without a model | Stream decision | | Rich behaviour across the whole entity | Aggregate, without reads of the same source | | Another month or another source | `IDecisionReads` stream overload in `Provide()` | | Uniqueness across sources | Constraint | ## Modes ### What are Off, Measure and On? `Off` is today’s behaviour and the default. The next major version of Arc makes `On` the default. `Measure` changes nothing and reports what `On` would do. `On` guards read-model parameters and refuses reads it cannot guard. See [Adopting read protection](/arc/backend/csharp/chronicle/consistency/adopting/). ### Where do I set the mode? In the project: `On`. It is compiled into the assembly. A command can override it with `[ReadProtection(ReadProtectionMode.On)]`. There is no setting in `appsettings` that raises it. ### Can I change the mode at run time? Only lower it, with `ReadProtection:MaximumMode`. See [Cap the mode at run time](/arc/backend/csharp/chronicle/consistency/adopting/#cap-the-mode-at-run-time). ### Why is protection opt-in? A protected command fails closed, and existing commands read plain models in many places. Turning it on by default would turn commands that work into errors, which is why the change waits for a major version. See [Why protection is opt-in](/arc/backend/csharp/chronicle/consistency/#why-protection-is-opt-in). ### Will the default change? Yes. The next major version of Arc turns read protection `On` by default, because a decision that holds is the safer starting point. You can turn it off for one command with `[ReadProtection(ReadProtectionMode.Off)]` or `[Unprotected("why")]`, for one read with `[Unprotected("why")]`, and for a project with `Off`. Set the property explicitly now to keep today’s behaviour through the upgrade. See [The default changes in the next major version](/arc/backend/csharp/chronicle/consistency/#the-default-changes-in-the-next-major-version) and [Cratis/Arc#3255](https://github.com/Cratis/Arc/issues/3255). ### What does Measure cost? One awaited tail read per command, with a timeout (250 ms by default), and background scans on a sample of commands (10 percent by default). No benchmark is published yet. ## Reads ### Which read models can I take as a parameter under On? Flat, event-source-keyed projections with an `Id` of a string or `Guid` type, and unfiltered reducers or self-reducing models with no constructor dependencies other than `ILogger`. Joins, children, by-property keys, stream-keyed projections, filtered reducers and Pinned delivery are refused. See [Reads](/arc/backend/csharp/chronicle/consistency/reads/). ### Why was my reducer refused? It has a filter or constructor dependencies. Chronicle cannot replay a reducer exactly when it filters its events or depends on a service. Remove the filter or the dependencies, read a projection of the same events, or mark the read `[Unprotected("why")]`. See [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/#read-model-parameters). ### How do I find a refused read before production? Run the application in `Measure` and read `refused_read`, turn the build-time diagnostics up ([ARCCHR0024](/arc/backend/csharp/chronicle/code-analysis/arcchr0024/), [ARCCHR0028](/arc/backend/csharp/chronicle/code-analysis/arcchr0028/)), and run a scenario for every command under `On`. ### What does `[Unprotected]` do? It declares a read advisory: it is read, and guards nothing. On a parameter or model type it covers that read. On a `Handle()` or `Provide()` method it covers the calls inside the body, not the parameters. On a command it turns read protection off for that command. See [Declare an advisory read](/arc/backend/csharp/chronicle/consistency/reads/#declare-an-advisory-read). ### What is `[LookUpBy]` for? It names the command property that selects the instance a parameter reads. Use it when a command reads two models by different keys, or when the model’s key is not the command’s event source id. See [Choose the key with LookUpBy](/arc/backend/csharp/chronicle/consistency/reads/#choose-the-key-with-lookupby). ### What happens if I do not turn read protection on? A plain parameter stays a plain read, as it always was. Nothing is guarded. A model declaring `[EventStream(stream)]` still requires a matching command route and explicit stream id: an undefined stream read is refused in every mode. Models without that declaration retain their existing behavior. Set `On`, or move there through `Measure`. ### Do I still need `DecisionRead` and `[ProtectedDecision]`? Not under `On`: a plain parameter can replace `DecisionRead`. A `[ProtectedDecision]` command with `DecisionRead` keeps working. Adding `On` is not redundant: it also protects plain read-model parameters and validator constructor reads and changes conflict diagnostics. Outside `On`, `[ProtectedDecision]` is how a command opts in one at a time. ## Conflicts ### Does Arc retry a command that conflicted? No. A guarded read that changed returns a `400` with the reason `concurrencyViolation` and the detail `ReadChanged`, and nothing is written. The client reads again and resubmits. See [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/). ### What does a guard not cover? `Revise` and `Redact`, projection definitions changed in another process, other event sequences, external state, and a writer that decides without a guard. See [How it works](/arc/backend/csharp/chronicle/consistency/how-it-works/#what-is-outside-the-guard). ### Why is the command’s key important? The key is the event source a read is guarded on, and the stream the events are written to. A command with no key gets a new random id for each call, so nothing can conflict. See [Pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/#no-event-source-id-or-two-candidates). ## Validators ### Can I use a `CommandValidator` for a state rule? Yes, and it is the recommended place. Under `On` its read-model constructor parameters are guarded. Under `Off` it is a hint. See [Validators](/arc/backend/csharp/chronicle/consistency/validators/). ### Can I check the rule on the validate endpoint? Yes. `{url}/validate` runs the validators with fresh reads. Nothing is guarded or written there, and a success does not promise that the command will succeed. ### Why is my validator refused under On? Arc builds the validator for each command so its parameters belong to that command. A validator registered by hand as scoped, singleton, a factory or an instance is refused. Let convention discovery register it. See [Validators](/arc/backend/csharp/chronicle/consistency/validators/#what-a-validator-may-and-may-not-do-under-on). ## Costs ### What does a guarded read cost? A fresh fold of the key’s history, two tail reads, and a retry if events arrive during the fold. Keep models flat and keys short-lived. See [How it works](/arc/backend/csharp/chronicle/consistency/how-it-works/#what-it-costs). ### How do I keep it cheap? Key by period, order or booking instead of by company; model one rule per projection; use a constraint for uniqueness; use `[Unprotected]` for hints. ## Failures ### Which failures are silent? A uniqueness check without a constraint, reads under `Off`, `Measure`, `[Unprotected]` reads and unknown dependencies, an ignored aggregate commit, a command with no key, `concurrency: true` used as a guard, and writers that decide without a guard. See [Pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/). ### Which analyzers help? [ARCCHR0002](/arc/backend/csharp/chronicle/code-analysis/arcchr0002/), [ARCCHR0011](/arc/backend/csharp/chronicle/code-analysis/arcchr0011/), [ARCCHR0012](/arc/backend/csharp/chronicle/code-analysis/arcchr0012/), [ARCCHR0024](/arc/backend/csharp/chronicle/code-analysis/arcchr0024/), [ARCCHR0028](/arc/backend/csharp/chronicle/code-analysis/arcchr0028/), [ARCCHR0033](/arc/backend/csharp/chronicle/code-analysis/arcchr0033/), [ARCCHR0034](/arc/backend/csharp/chronicle/code-analysis/arcchr0034/), [ARCCHR0035](/arc/backend/csharp/chronicle/code-analysis/arcchr0035/) and [ARCCHR0036](/arc/backend/csharp/chronicle/code-analysis/arcchr0036/). The [index](/arc/backend/csharp/chronicle/code-analysis/) lists all rules. ## Testing ### How do I test that a rule holds under concurrency? Use `new CommandScenario()`, seed real events with `Given`, and queue a competing event with `AppendConcurrently(...)`. See [Test the race](/arc/backend/csharp/chronicle/consistency/scenarios/testing/). ## Event source definitions and stream decisions ### Does an event source definition make my rule hold? No. A definition gives the append a boundary: the event source id narrowed by the dimensions it declares. Under `Off` Arc resolves the expected tail after `Handle()` returns, so the check cannot bind your decision to what you read. Use a constraint, a guarded read, a stream decision, an aggregate or an exact scope for that. See [what the definition guards](/arc/backend/csharp/chronicle/commands/event-source-definitions/#what-the-definition-guards). ### What is the difference between a definition, `[EventSourceType]` and an explicit concurrency scope? | | `[EventSource(stream)]` | `[EventSourceType]` and `[EventStreamType]` | Explicit scope (`EventsWithConcurrencyScopes`) | | --- | --- | --- | --- | | What it is | A typed definition with named streams and dimensions | Strings on the command | A scope you build from a revision you read | | Validated | An undeclared stream fails; contradicting string attributes fail | No: any string is accepted | Not against a definition | | Concurrency dimensions | Declared once on the definition | Chosen per command with `concurrency: true` | You state them in the scope | | Binds the decision to what it read | No | No | Yes, as far as where you captured the revision | | Wins over the others | Is the default | Overrides the definition when `concurrency: true` | Replaces every derived scope | Use a definition for routing and the default boundary, a stream decision or an explicit scope when the decision depends on what it read. See [migrate from string routing](/arc/backend/csharp/chronicle/commands/event-source-definitions/#migrate-from-string-routing). ### What is the difference between a stream decision and a read model parameter? | | Stream decision (`IStreamReads`) | Read-model parameter under `On` | | --- | --- | --- | | Facts | Raw events in one routed stream | A projection folded from the events of one key | | Declared with | A route plus a stream id | The parameter | | Boundary | The stream’s tail, captured before the read | The log’s tail, captured before the fold, for the folded event types | | Narrowed by event type | No: every event in the stream conflicts | Yes: the event types the model folds | | Cost | The whole stream crosses the wire per command | A fresh fold of the key’s history per read | A stream decision requires a specific event source id, stream type and stream id; otherwise it throws `StreamDecisionRequiresSpecificRoute`. A stream decision beside a whole-source read of the same source in one command is refused, because the stream scope is narrower than the read. See [stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/). ### Can one command guard two streams of the same event source? Not with independent guards. `EventsWithConcurrencyScopes` carries one scope per event source id per command. Guard the whole stream type with `reads.StreamType(...)`, or split the command. See [keep one exact scope per source](/arc/backend/csharp/chronicle/commands/stream-decisions/#keep-one-exact-scope-per-source). ## Dynamic Consistency Boundary ### How does this relate to the Dynamic Consistency Boundary? Chronicle expresses the DCB append condition: a guard plays the part of one query item with a position, and an append of several event sources is atomic. Chronicle does not use tags in a guard, so a fact about two entities is written as one event per entity. See [Dynamic Consistency Boundary](/arc/backend/csharp/chronicle/consistency/#dynamic-consistency-boundary), the [concept in Chronicle](/chronicle/dynamic-consistency-boundary/), [Chronicle’s choosing guide](/chronicle/dynamic-consistency-boundary/choosing/) and [its FAQ](/chronicle/dynamic-consistency-boundary/faq/). # Getting started with read protection > Turn read protection On, write a first protected command with a validator over a read model, and see what happens when two requests race. Use this page when you want one command whose rule holds under concurrent requests. It takes about ten minutes. You need an Arc application with Chronicle (see [Add event sourcing to an Arc slice](/arc/backend/csharp/chronicle/add-event-sourcing/)) and a Chronicle version that includes read protection (19.68.0 or later). ## 1. Turn read protection On Set the mode in the project that contains your commands, or in a `Directory.Build.props` that covers several projects. ```xml On ``` The value must be `Off`, `Measure` or `On`. Any other value fails the build with `CratisReadProtection must be Off, Measure, or On.` The property is compiled into the assembly. It is not read from configuration, so analyzers and tests see the same mode that runs in production. To turn it on for one command instead, put `[ReadProtection(ReadProtectionMode.On)]` (namespace `Cratis.Arc.Commands`) on the command. A command-level value overrides the assembly’s value. ## 2. Write a protected command The rule: a book cannot be borrowed when no copy is left. The rule goes in a `CommandValidator` that takes the read model as a constructor parameter. `Handle()` says what happens. The command-level declaration keeps this example protected when copied into a project that has not enabled `On` assembly-wide. ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Queries.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; using Cratis.Concepts; using FluentValidation; public record Isbn(string Value) : EventSourceId(Value); public record MemberId(Guid Value) : ConceptAs(Value); [EventType] public record CopyAdded(); [EventType] public record BookBorrowed(MemberId Member); [EventType] public record BookReturned(MemberId Member); [ReadModel] public record BookStock( Isbn Id, [Increment, Decrement, Increment] int Available); [Command, ReadProtection(ReadProtectionMode.On)] public record BorrowBook(Isbn Isbn, MemberId Member) { public BookBorrowed Handle() => new(Member); } public class BorrowBookValidator : CommandValidator { public BorrowBookValidator(BookStock? stock) => RuleFor(command => command.Isbn) .Must(_ => stock is { Available: > 0 }) .WithMessage("No copies of this book are available."); } ``` What makes this work: * `Isbn` derives from `EventSourceId`, so Arc uses it as the command’s event source id. No `[Key]` is needed. It is also the key of the read. * `MemberId` is a `ConceptAs`, not an `EventSourceId`: it refers to another entity and this command does not append to it. A command with two `EventSourceId` properties is ambiguous unless `Handle()` returns `EventForEventSourceId` or the command implements `ICanProvideEventSourceId`; see [ARCCHR0002](/arc/backend/csharp/chronicle/code-analysis/arcchr0002/). * `BookStock` is a model-bound projection of the book’s own events. It has an `Id` property of a string-like or `Guid` type. The key is matched to it. * `BookStock?` is nullable because a book with no events has no read model yet. A non-nullable parameter fails the command with `ReadModelDoesNotExistForCommand` instead. * The rule is in the validator because that is where a “may this command proceed” rule belongs. Under `On` the validator’s read-model parameter is guarded. See [Validators](/arc/backend/csharp/chronicle/consistency/validators/). ## 3. What happens when two requests race 1. Arc starts the command’s unit of work and turns on read protection for the whole invocation. 2. Before the validator runs, Chronicle captures the position of the event log, then folds `BookStock` from the book’s own events. 3. The validator decides from that instance. `Handle()` returns `BookBorrowed`. 4. Arc commits. Chronicle appends the event and rejects the append if a `CopyAdded`, `BookBorrowed` or `BookReturned` for that book arrived after the position it captured. 5. On a conflict nothing is written. The caller receives a `400` with this message: ```text BookStock 'isbn-1' changed after BorrowBook read it (BookBorrowed at 1042). Nothing from this command was written; submit it again. ``` The validation result has the reason `concurrencyViolation` and the detail `ReadChanged`. Arc and Chronicle do not re-run the command; the fold may retry up to three times. The client keeps the user’s input, reads again and offers resubmission. The guard for this command is: ```text guard isbn-1: types=[CopyAdded, BookBorrowed, BookReturned], after ``` A concurrent event of another type on the same book, for example a rename, does not conflict. A borrow of another book does not conflict. ## 4. See a refusal Under `On`, a read Arc cannot guard is refused at once, instead of running unguarded. Add a plain `IReadModels` read to `Handle()`: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.ReadModels; [Command] public record LendBook(Isbn Isbn, MemberId Member) { public async Task Handle(IReadModels readModels) { var stock = await readModels.GetInstanceById(Isbn); return new(Member); } } ``` The command fails with a `500`: ```text LendBook called GetInstanceById while read protection is On. Take a read model as a parameter, use IDecisionReads.Get in Provide, or mark the method [Unprotected("why")]. ``` The fix is the parameter from step 2. If the read is only context, mark the method or the read `[Unprotected("why")]`. [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/) lists every refusal with its fix. ## 5. Test the race A scenario test appends a competing event after the command read and before it committed. See [Test the race](/arc/backend/csharp/chronicle/consistency/scenarios/testing/). ## Where to go next * [Adopting read protection](/arc/backend/csharp/chronicle/consistency/adopting/): turn it on in an existing application without breaking it. * [Reads](/arc/backend/csharp/chronicle/consistency/reads/): what is protected, advisory and refused. * [Scenarios](/arc/backend/csharp/chronicle/consistency/scenarios/): the other rule shapes. # How read protection works > What Arc does for each command under On, how a guard is derived from your reads, and how guards merge and widen. Use this page when you want to know exactly which events make a command fail, or why a guard is wider than you expected. You do not need it to use read protection. [Getting started](/arc/backend/csharp/chronicle/consistency/getting-started/) is enough. ## What Arc does for a command | Step | What happens | | --- | --- | | 1. Begin | Before the filters run, Arc starts the command’s unit of work, claims ownership of it, and turns on read protection for the whole invocation. Nested commands share the same unit | | 2. Resolve parameters | Each read-model parameter, in `Handle()`, `Provide()` and validator constructors, is read fresh through Chronicle’s decision-read path and enrolled as a guard **before** your code sees the value | | 3. Decide | Validators run, then `Provide()`, then `Handle()`. Reads of the same model and key inside one command share one fold | | 4. Commit | Arc commits as the owner. Chronicle merges every guard into the conditions the kernel checks with the append | | 5. End | Arc ends read protection after stream completions have run. A conflict writes nothing | Chronicle’s side of this is the [read-protection frame](/chronicle/read-models/read-protection/), and Arc is the host that drives it. If you use Chronicle directly, you drive it yourself. ## Where a guard comes from A guard is an append condition: a position in the log plus a filter. The kernel rejects the append if an event that matches the filter was appended after the position. | Part | For a read-model parameter | | --- | --- | | Event source | The key the parameter reads | | Event types | The types the model folds, including the types that remove it | | Position | The tail of the log, captured **before** the model was folded | Capturing the position before the fold is what makes the guard sound. An event that lands during the fold, or after it, is after the position and is detected. The examples in these pages show the guard as one line. It is a notation for the docs. `IUnitOfWork.GetAppendConditions()` returns the real conditions. ```text // BookStock for isbn-1 guard isbn-1: types=[CopyAdded, BookBorrowed, BookReturned], after ``` | Event arrives for | Conflicts? | | --- | --- | | The same book, one of the model’s types | Yes | | The same book, another type (a rename) | No | | Another book | No | A model that does not exist yet is guarded too. If no matching event existed at the read, an event of the model’s types that arrives later conflicts. That is how “create it only if it does not exist yet” holds. ## Several reads Reads of the same event source merge into one guard. The merged guard can only get wider, never narrower, so it never misses a change that either read would have caught. | What the reads share | The merged guard | | --- | --- | | Same source | The union of the event types, from the earliest position | | Different sources | One guard per source, validated together with the append | ```text // Transfer: AccountBalance for acc-1 and AccountBalance for acc-2 guard acc-1: types=[Deposited, Withdrawn], after guard acc-2: types=[Deposited, Withdrawn], after ``` Both guards are checked in the same atomic append. A conflict on either one rejects the whole batch. ## An explicit scope beside a read If a command returns an exact scope (`EventsWithConcurrencyScopes`) for a source it also reads, Chronicle checks that the scope covers the read: * the scope’s source, types and stream dimensions are each unset or at least as wide as the read’s; * the scope’s position is the same as or earlier than the read’s. If it does, the scope is used. If not, the command fails with `ExplicitScopeDoesNotCoverReads`. A stream decision is an explicit scope, so a stream decision beside a whole-source read of the same source is refused, because the stream is narrower than the read. [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/) has the fixes. ## Declared concurrency on a definition An [event source definition](/arc/backend/csharp/chronicle/commands/event-source-definitions/) can declare which dimensions guard an append. Under `On`, Arc does not build the routing scope itself. It hands the declared dimensions to Chronicle, which merges them with the read at commit. | Definition | Under `On` | | --- | --- | | Declares nothing | Adds nothing | | Declares dimensions including the event source id | Merged into the read’s guard. The declared dimensions are kept only where they equal the read’s, and the event types become any type | | Declares dimensions without the event source id | Kept as a separate guard, not tied to the read. It does not protect the state you read | A definition that declares `Concurrency = EventSourceId | EventStreamType` makes the guard of a `Withdraw` command wider: ```text // without the definition's dimensions guard acc-1: types=[MoneyDeposited, MoneyWithdrawn], after // with the definition's dimensions merged in guard acc-1: any type, after ``` Declare `Concurrency` only for ledgers, numbering and approval streams, where every writer should see every other. For anything else it only adds conflicts. See [Chronicle: how guards are derived](/chronicle/dynamic-consistency-boundary/how-guards-are-derived/) for the full merge rules and the notation. ## Aggregates and stream decisions | Dependency | Guard | | --- | --- | | Aggregate root | Enrolled when the aggregate is rehydrated: its source and the event types it handles. An aggregate and a plain read of the same source are refused | | Stream decision | An explicit scope enrolled when the stream is read: the routed stream, any event type | | Fresh-identifier check | An expected-empty guard on the new id | ## What is outside the guard | Situation | What happens | | --- | --- | | A writer that decides without a guard | It can still break the rule. A guarded writer detects its event | | `Revise` or `Redact` between the read and the append | Not detected. They do not move the tail. Chronicle refuses them inside a protected invocation | | A projection definition changed in another process | Not detected until this client registers again. Quiesce commands during a projection deploy | | Other event sequences, external state, other databases | Not guarded | | A command that appends nothing | A guard that exists only because of a read sends nothing. No write means nothing to protect | | Events that arrive after the commit | Not part of the decision | ## What it costs Each guarded read makes two tail reads and a fold of the key’s history, and retries the fold when events arrive during it, up to three attempts. The first read of a model type in a process also checks the projection definition. Identical reads inside one command share one fold. The kernel serves the fold through a projection that is shared by every key of a model type, so heavy use of one model type is serialized across keys and tenants. No benchmark for read protection is published yet. Keep guarded models flat and keys short-lived. ## Where to go next * [Reads](/arc/backend/csharp/chronicle/consistency/reads/): protected, advisory and refused reads. * [Chronicle: how guards are derived](/chronicle/dynamic-consistency-boundary/how-guards-are-derived/): the full rules, with examples. * [Chronicle: read protection](/chronicle/read-models/read-protection/): the API Arc drives. * [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/): the scope Arc builds when read protection is off. # Pitfalls that fail silently > The ways a rule stops holding without an error, with the analyzer that reports each and the fix. Use this page when a rule holds in every test and still breaks in production. These failures do not raise an error. Failures that do raise one, such as a refused read or a conflict, are on [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/). ## Summary | Pitfall | Reported by | Fix | | --- | --- | --- | | [A uniqueness check instead of a constraint](#a-uniqueness-check-instead-of-a-constraint) | Nothing | Constraint | | [A read that is not protected](#a-read-that-is-not-protected) | [ARCCHR0011](/arc/backend/csharp/chronicle/code-analysis/arcchr0011/) (Info, `Off`) | Turn read protection `On` | | [Caller-skipped validator errors under Off](#caller-skipped-validator-errors-under-off) | Nothing | Declare a blocking severity policy | | [An immediate append under Off](#an-immediate-append-under-off) | [ARCCHR0007](/arc/backend/csharp/chronicle/code-analysis/arcchr0007/) | Return the events | | [An aggregate commit whose result is ignored](#an-aggregate-commit-whose-result-is-ignored) | Nothing | Return `Commit()` | | [No event source id, or two candidates](#no-event-source-id-or-two-candidates) | [ARCCHR0002](/arc/backend/csharp/chronicle/code-analysis/arcchr0002/) for two candidates | `EventSourceId` or `ICanProvideEventSourceId` | | [A model that folds a different key than the facts are written to](#the-model-folds-a-different-key-than-the-one-the-facts-are-written-to) | Nothing | Write the facts to the key you read | | [`concurrency: true` as a guard](#concurrency-true-is-not-the-guard) | [ARCCHR0016](/arc/backend/csharp/chronicle/code-analysis/arcchr0016/) only with exact scopes | A real guard | | [An advisory reason that stopped being true](#an-advisory-reason-that-stopped-being-true) | [ARCCHR0032](/arc/backend/csharp/chronicle/code-analysis/arcchr0032/) for inert declarations | Review `[Unprotected]` | | [A lowered mode](#a-lowered-or-measured-mode) | A startup Warning | Remove the cap | | [Results you must check](#results-you-must-check-when-you-use-chronicle-directly) | Nothing | Check `IsSuccess` | | [The first append into a scope](#the-first-append-into-a-scope) | Nothing | Opt in | | [Where the guard stops](#where-the-guard-stops) | Nothing | Outside the guard | | [Tests without the in-process log](#tests-without-the-in-process-log) | Nothing | A command declaring read protection and real seeded events | All examples on this page share one namespace. ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; [EventType] public record CopyAdded(); [EventType] public record BookBorrowed(Guid MemberId); [EventType] public record BookReturned(); [EventType] public record LoanTaken(string Isbn); [EventType] public record LoanReturned(string Isbn); [EventType] public record MemberRegistered(string Email); public record BookStock( EventSourceId Id, [Increment, Decrement, Increment] int Available); public record MemberLoans( EventSourceId Id, [Increment, Decrement] int Active); ``` ## Choosing the wrong stream boundary | Rule lives in… | Use | | --- | --- | | Folded state of one stream on the command’s own route | [Per-stream model](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/) | | The entity’s whole history, such as a balance | Whole-source model. Combining it with a per-stream model gives a whole-source guard | | “Has fact X happened in this short stream?”, without a model | Stream decision | | Rich behaviour across the whole entity | Aggregate, without reads of the same source | | Another month or another source | `IDecisionReads` stream overload in `Provide()` | | Uniqueness across sources | Constraint | These cases differ: do not treat a safe widening as a missed conflict. | Pitfall | Outcome | Fix | | --- | --- | --- | | Source-level `Concurrency = EventSourceId` falls back onto a stream command | The guard widens to the source; other months can conflict. [ARCCHR0038](/arc/backend/csharp/chronicle/code-analysis/arcchr0038/) reports it | Use no declared concurrency for the model’s exact event types, or declare all three source-id/stream dimensions on the stream | | A per-stream model is queried by key alone | Loud refusal: `ReadModelRequiresStreamScope` | Use the plain `IReadModels.GetInstanceById(EventSourceId, EventStreamId)` overload; no watcher | | A command parameter’s route does not match its model declaration | Loud `ReadModelStreamNotOnCommandRoute` in every mode; [ARCCHR0037](/arc/backend/csharp/chronicle/code-analysis/arcchr0037/) | Match the definition and stream and declare an explicit stream id | | A writer bypasses the definition route and omits its source type | Its events are neither folded nor guarded by the model: silent and outside the guard | Route every writer through the same definition | ## A uniqueness check instead of a constraint A check followed by an append leaves a gap, with or without read protection. Two requests both see “not taken” and both append. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Monads; public interface IMemberDirectory { Task EmailExists(string email); } [Command] public record RegisterCheckedMember(string Email) { public async Task> Handle(IMemberDirectory directory) => await directory.EmailExists(Email) ? ValidationResult.Error("A member with this email address is already registered.") : new MemberRegistered(Email); } ``` `IMemberDirectory` is an unknown dependency: read protection cannot see it, so nothing reports it, and it works in every test that runs one request at a time. Use a [constraint](/arc/backend/csharp/chronicle/consistency/scenarios/unique-and-once/). The same holds for a validator that asks the question. ## A read that is not protected Read protection guards the reads it can see. These stay silent: | Situation | Why it is silent | | --- | --- | | The command or its project is `Off` or `Measure` | A plain read is a snapshot. `Measure` only reports | | A read marked `[Unprotected]` | It is declared advisory. Nothing guards it, and a reviewer must judge the reason | | A service, another database or an HTTP call | Unknown. Outside the guard | | A model type marked `[Unprotected]` | Every command that reads it treats it as advisory | Under `Off`, [ARCCHR0011](/arc/backend/csharp/chronicle/code-analysis/arcchr0011/) reports plain reads in event-producing commands at Info severity, which is easy to miss in a build log. Raise it for projects that have not moved yet: ```ini [*.cs] dotnet_diagnostic.ARCCHR0011.severity = warning dotnet_diagnostic.ARCCHR0012.severity = warning ``` ## Caller-skipped validator errors under Off Under `Off` or `Measure`, a caller sending `X-Allowed-Severity: 3` can skip validator errors and let `Handle()` run unless the command declares a blocking policy. If you rely on validators under `Off`, use `[BlockOnValidationSeverity(ValidationResultSeverity.Error)]` from `Cratis.Arc.Commands.ModelBound`. This keeps errors blocking; it does not guard the read or close the gap before append. Commands declaring `On` get this Error floor implicitly, including when an operational cap lowers read protection. An explicit blocking-severity attribute takes precedence. See [Validation severity filtering](/arc/backend/csharp/commands/validation-severity-filtering/). ## An immediate append under Off Injecting `IEventLog` and calling `Append` writes at once. The append reads the tail at the moment you call it, ignores the command’s unit of work and its guards, and stays in the log if the command later fails. Arc watches the appends: a failed immediate append fails the command, but a successful one cannot be undone. Under `On` this is refused (`ImmediateAppendWhileReadsAreProtected`). Under `Off` and `Measure` it is silent. [ARCCHR0007](/arc/backend/csharp/chronicle/code-analysis/arcchr0007/) reports `IEventLog` parameters on `Handle()` and `Provide()`, and [ARCCHR0012](/arc/backend/csharp/chronicle/code-analysis/arcchr0012/) reports the direct append in a `[ProtectedDecision]` command and under `On`. Return the event from `Handle()`, or stage it with `eventLog.Transactional.Append(...)`. ## An aggregate commit whose result is ignored An aggregate’s `Failed(...)` accumulates errors, and only `Commit()` turns them into a result. ```csharp using Cratis.Arc.Chronicle.Aggregates; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [EventType] public record CopyLent(Guid MemberId); public class Copy : AggregateRoot { bool _lent; public async Task Lend(Guid memberId) { if (_lent) { Failed("The copy is already lent."); return; } await Apply(new CopyLent(memberId)); } public void OnCopyLent(CopyLent @event) => _lent = true; } [Command] public record LendCopyIgnoringTheResult(EventSourceId CopyId, Guid MemberId) { public async Task Handle(Copy copy) { await copy.Lend(MemberId); await copy.Commit(); } } [Command] public record LendCopyReturningTheResult(EventSourceId CopyId, Guid MemberId) { public async Task Handle(Copy copy) { await copy.Lend(MemberId); return await copy.Commit(); } } ``` The first command ends successfully even when the copy was already lent: the failure is in the result that nothing reads. The second returns it, and Arc turns it into the command’s outcome. `Commit()` is also the last staging step. It commits the whole command unit, so do not stage anything afterwards. Under `On`, a read or an append after `Commit()` is refused, and [ARCCHR0035](/arc/backend/csharp/chronicle/code-analysis/arcchr0035/) reports it at build time. ## No event source id, or two candidates A command with no event source id property gets a new random id on every call, so nothing can ever conflict. A raw `Guid` is not an event source id. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [EventType] public record PaymentRecorded(decimal Amount); [Command] public record RecordPaymentToRandomStream(Guid AccountId, decimal Amount) { public PaymentRecorded Handle() => new(Amount); } ``` Declare the id as `EventSourceId` (or a type derived from it) so the event goes to the account’s stream. When a command has two such properties, Arc uses the first one declared, which also becomes the key of a read, and [ARCCHR0002](/arc/backend/csharp/chronicle/code-analysis/arcchr0002/) is the only signal (a warning). `[Key]` on one of them does not silence it. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; [EventType] public record LoanRecorded(EventSourceId Member, EventSourceId Book); [Command] public record RecordLoanWithTwoKeys(EventSourceId Member, EventSourceId Book) { public LoanRecorded Handle() => new(Member, Book); } [Command] public record RecordLoanWithAKey(EventSourceId Member, EventSourceId Book) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => Member; public LoanRecorded Handle() => new(Member, Book); } ``` The second command is the fix: `ICanProvideEventSourceId` names the stream explicitly. See [resolving the event source id](/arc/backend/csharp/chronicle/resolving-event-source-id/). A command that appends to both entities returns `EventForEventSourceId` for each; the analyzer accepts that too. ## The model folds a different key than the one the facts are written to A guarded read folds the events of its key. Events written to another event source are invisible to it. ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using FluentValidation; [Command] public record TakeLoanOnItsOwnStream(EventSourceId Member, string Isbn) { public EventForEventSourceId Handle() => new(EventSourceId.New(), new LoanTaken(Isbn)); } public class TakeLoanOnItsOwnStreamValidator : CommandValidator { public TakeLoanOnItsOwnStreamValidator(MemberLoans? loans) => RuleFor(command => command.Isbn) .Must(_ => loans is not { Active: >= 3 }) .WithMessage("The member has reached the loan limit."); } ``` `MemberLoans` counts `LoanTaken` on the member’s stream, but each loan is written to a new stream. The count is always zero, the limit never triggers, and the guard watches a stream no one writes to. Write the facts to the key you read (`return new LoanTaken(Isbn)` goes to `Member`), or model the rule on one key that carries all of them. A rule over keys that can join the set later, such as loans that each live on a book stream, is not guarded: a new key is in nobody’s read set. ## `concurrency: true` is not the guard `concurrency: true` on `[EventSourceType]`, `[EventStreamType]` and `[EventStreamId]` narrows the check Arc builds for returned events. That check reads the tail after `Handle()`, so it cannot bind your decision to what you read. ```csharp using Cratis.Arc.Chronicle.ReadModels; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Chronicle.Events; using Cratis.Chronicle.ReadModels; using Cratis.Monads; [Command] [EventSourceType("Book", concurrency: true)] [Unprotected("deliberately advisory")] public record BorrowBookNarrowed(EventSourceId BookId, Guid MemberId) { public async Task> Handle(IReadModels readModels) { var stock = await readModels.GetInstanceById(BookId); return stock is { Available: > 0 } ? new BookBorrowed(MemberId) : ValidationResult.Error("No copies are available."); } } ``` Use `concurrency: true` to choose which dimensions bound the default check, not to protect a decision. [ARCCHR0016](/arc/backend/csharp/chronicle/code-analysis/arcchr0016/) reports the flag only when the same command also returns exact scopes, where it has no effect. ## An advisory reason that stopped being true `[Unprotected("why")]` is a promise that a stale read is acceptable. The reason is only text. Put the attribute on the parameter rather than the model type unless every command that reads the type can tolerate staleness, and review the reasons when a model starts to feed a decision. [ARCCHR0032](/arc/backend/csharp/chronicle/code-analysis/arcchr0032/) reports declarations that have no effect. ## A lowered or measured mode Two settings leave the application looking protected while it is not: * `Measure` reports what `On` would do. It never guards. * `ReadProtection:MaximumMode` lowers the mode at run time. Arc logs a Warning at startup for each lowered command and reports `mode_lowered` in Measure telemetry, but the commands run unprotected. Check that no cap is set in production configuration unless you set it on purpose. See [Adopting read protection](/arc/backend/csharp/chronicle/consistency/adopting/#cap-the-mode-at-run-time). ## Results you must check when you use Chronicle directly Outside a command, nothing checks results for you. ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Microsoft.AspNetCore.Http; using ScopeBuilder = Cratis.Chronicle.EventSequences.Concurrency.ConcurrencyScopeBuilder; [EventType] public record Withdrawn(decimal Amount); public static class DirectAppends { public static async Task IgnoresTheResult(IEventLog eventLog, EventSourceId accountId) { await eventLog.Append(accountId, new Withdrawn(10m)); return Results.Ok(); } public static async Task BuildsAnIncompleteScope(IEventLog eventLog, EventSourceId accountId) { var scope = new ScopeBuilder().WithEventSourceId(accountId).Build(); var result = await eventLog.Append(accountId, new Withdrawn(10m), concurrencyScope: scope); return result.ConcurrencyCheckPerformed; } } ``` * `Append` returns an `AppendResult`: a constraint violation or a concurrency violation is a value, not an exception. `IgnoresTheResult` reports success whatever happened. Check `IsSuccess`. * A scope with no sequence number and no `ExpectingNoMatchingEvent()` is skipped by the kernel, and `ConcurrencyCheckPerformed` is `false`. * With `IDecisionReads.Get` and a unit of work, commit with `IUnitOfWorkCompletionFeature.CommitAsync()` before you respond. The request middleware commits only after the endpoint has returned, and it reports no concurrency violation to the caller. * A plain `Append` after `Get` in the same request is written immediately and bypasses the read’s guard. Use `Transactional.Append`. * In an Arc project, qualify Chronicle’s `ConcurrencyScopeBuilder`: `Cratis.Arc.Chronicle.Commands` has a static class with the same name. [Chronicle used directly](/arc/backend/csharp/chronicle/consistency/scenarios/chronicle-direct/) shows the correct shapes. ## The first append into a scope The first event on a new event source, or the first with a new event source type, stream type or stream id, has no tail to compare against. By default Chronicle skips the check, so two concurrent creations can both succeed. Opt in application-wide, or ask for it per append with `ExpectingNoMatchingEvent()`: ```csharp using Cratis.Chronicle; public static class FirstAppendCheck { public static void Configure(ChronicleOptions options) => options.ConcurrencyOptions.CheckFirstAppendIntoAScope = true; } ``` The option rejects appends that succeed today, and its default is planned to change in the next major version. Read [concurrency in Chronicle](/chronicle/events/concurrency/) before you turn it on. A new aggregate and a guarded read already check their first append. ## Where the guard stops The guard covers the events in the read set. It does not cover: * `Revise` and `Redact` between the read and the append; * a projection definition changed by another process, until this client registers again; * other event sequences and external or non-Chronicle state; * a unique-constraint index update that fails after the append is durable: Chronicle logs it, a later duplicate can pass, and a reindex repairs it; * constraints across namespaces: tenants in different namespaces can claim the same value. The guard also does not protect a writer that decides without one. A guard detects every matching event appended after its boundary, whoever wrote it. It only protects decisions that carry a guard, so the rule holds only when every writer that decides on it is guarded, or when it is a constraint, which the kernel checks for every append. ## Tests without the in-process log `new CommandScenario()` automatically selects the in-process log when the command declares read protection or decision dependencies. That setup does not protect an `Off` command by itself. `AppendConcurrently` queues a competing writer before the command commits, so test a command whose declaration and reads actually enroll a guard. Pinned read models cannot supply a guard; seed real events instead. See [Test the race](/arc/backend/csharp/chronicle/consistency/scenarios/testing/). ## Where to go next * [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/): the decision matrix. * [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/): the failures that raise an error. * [Scenarios](/arc/backend/csharp/chronicle/consistency/scenarios/): the correct shape for each rule. * [Code analysis](/arc/backend/csharp/chronicle/code-analysis/): every `ARCCHR` rule. * [Chronicle’s Dynamic Consistency Boundary FAQ](/chronicle/dynamic-consistency-boundary/faq/): the same guards from Chronicle’s side. # Reads under read protection > Learn which command reads are protected, advisory, refused or unguarded under On, and how to declare an advisory read or choose a parameter's key. Use this page when you want to know what a read in a command becomes under `On`, how to mark one as advisory, or how to choose which key a parameter reads. Under `Off`, plain read-model parameters remain unguarded; explicit `[ProtectedDecision]` commands with `DecisionRead` still guard their declared decision reads. ## Four classes of read | Class | What it is | Under `On` | | --- | --- | --- | | **Protected** | A read model parameter, or `IDecisionReads.Get` in `Provide()`, that Chronicle can guard | Read fresh, and enrolled as a guard before your code sees the value | | **Advisory** | A read you marked `[Unprotected("why")]` | A stored read that guards nothing. The reason goes to telemetry | | **Refused** | Any other read Arc or Chronicle can see | The command fails with a `500` that names the read and the fix | | **Unknown** | A dependency Arc cannot see: a service, another database, an HTTP call | Outside the guard. Never reported as protected | ## What each read becomes | What you write in a command | Under `On` | | --- | --- | | A read-model parameter backed by a source-keyed projection (materialized, passive or model-bound) | **Protected.** The fold is read from the event log, not from the read model store | | A read-model parameter backed by an unfiltered reducer with no constructor dependencies other than `ILogger` (self-reducing models are exempt from that constructor restriction) | **Protected** | | A reducer with a filter or other constructor dependencies | **Refused** | | A model with joins, children, `FromEvery`, `FromEventProperty` or `UsingKey` | **Refused** | | A projection keyed by a stream | **Refused** as a plain parameter. Read it with `IDecisionReads.Get` and an explicit stream in `Provide()` | | A key that is not a string or a `Guid`; an ambiguous projection; a model of another event sequence; a definition that does not match the registered one | **Refused** | | A projection with Pinned generation delivery | **Refused** | | A model with no instance yet | **Protected.** A nullable parameter receives `null` and the guard watches for the first event. A required parameter rejects with a validation result | | `DecisionRead` with `[ProtectedDecision]` | Still supported. `On` additionally protects plain read-model parameters and validator constructor reads | | `IDecisionReads.Get` in `Provide()` | **Protected** | | `IDecisionReads.GetDetached` | **Refused** unless inside an advisory method | | `IReadModels`, raw `IEventStore`, `IEventLog` or `IEventSequence` reads, including inside a command filter | **Refused** at the call | | A read model injected into the constructor of a command filter or a service | **Refused** with `ReadModelInjectedOutsideDecision`, unless the read-model type is `[Unprotected("why")]`. Move the rule into `Handle()` or a validator. A model declared for one event stream is bound to the command’s route, as for a parameter | | `IMongoCollection` of a Chronicle-backed model | **Refused** when the parameter is resolved | | `IEventLog.Append` (an immediate append), anywhere in the invocation | **Refused**. `IEventLog.Transactional.Append` stages the event and is allowed | | An aggregate and a plain read of the same source | **Refused**. A plain read of a different source is fine | | A service, another database, HTTP | **Unknown** | Reads inside a validator constructor follow the same rules. See [Validators](/arc/backend/csharp/chronicle/consistency/validators/). [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/) lists each refusal with its message and fix. [How it works](/arc/backend/csharp/chronicle/consistency/how-it-works/) explains what a protected read guards. ## Missing models and invalid keys Under `On`, a missing required (non-nullable) protected read-model parameter rejects the command with HTTP `400` and the message `{ReadModel} '{key}' does not exist.`. `ReadModel` is the model’s simple CLR type name, matching its Screenplay view name. The validation result uses `ValidationResultReason.Rule` (`rule`), Arc’s ordinary validation reason. A nullable parameter still receives `null` for a valid key with no instance. A protected key is treated as missing if it is empty, whitespace-only, exactly `*`, contains `#`, or has leading or trailing whitespace. Arc checks the raw key and each composite key part before acquiring a read, even if an instance exists at that key. This rejection also applies to nullable protected parameters. Text keys appear unchanged in the message, including whitespace. For a composite record or tuple key, the diagnostic key text is empty (`Model '' does not exist.`), matching the Screenplay reference evaluator; concepts are unwrapped before checking their values. These rules apply to protected reads, not advisory reads. `Off` and `Measure` retain their existing lookup, nullability, messages and validation reasons. Other read-protection refusals retain their existing outcomes. ## Require generated key metadata Arc uses generated command property getters and read-model key types for command key lookup. Applications without that metadata retain reflection compatibility by default. Arc logs one warning per missing type, without key values, explaining how to enable the source generator. Set `ReadProtection:RequireGeneratedMetadata` to `true` to reject missing metadata with `CommandReadModelKeysMissing` instead of reflecting. Discovered commands running under `On` are checked at startup; command and model metadata used by lookup is also checked at resolution. Missing, invalid or `false` settings retain compatibility. Runtimes where `RuntimeFeature.IsDynamicCodeSupported` is `false` always require generated metadata; this does not make all of Arc NativeAOT-compatible. This setting is separate from `ProtectedReadModelKeyOptions.Strict`, which requires accessor coverage for composite key parts. Neither setting changes which property or key a lookup selects. ## Declare an advisory read `[Unprotected("why")]` is the opt-out. Write the reason as a sentence a reviewer can judge. The reason goes to telemetry as `advisory_read`. | Place | Covers | Notes | | --- | --- | --- | | A parameter | That parameter | The common case: context you read but do not decide from | | A read-model type | Every parameter of that type, in every command, and the type injected into a command filter or a service | Use for reference data you accept as stale | | A `Handle()` or `Provide()` method | **The method body only**: calls to `IReadModels` and other raw reads inside it | It does not cover the method’s parameters. [ARCCHR0034](/arc/backend/csharp/chronicle/code-analysis/arcchr0034/) reports a method-level declaration beside read-model parameters | | A command | The whole command runs as `Off` | Use it for one command you cannot move yet | ```csharp using Cratis.Arc.Chronicle.ReadModels; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; public record Isbn(string Value) : EventSourceId(Value); [EventType] public record BookSold(decimal Price); public record BookStock(EventSourceId Id, [Increment] int Sold); [FromEvent] public record BookPrice(EventSourceId Id, decimal Price); [Command] public record SellBook(Isbn Isbn) { public BookSold Handle( BookStock stock, [Unprotected("shown to the seller as context; the price is copied as of this read")] BookPrice price) => new(price.Price); } ``` Here `stock` is protected and `price` is not. The guard of this command covers `BookSold` for the book and nothing else. Do not use `[Unprotected]` to silence a refusal you do not understand. A refusal means Chronicle could not guard that read exactly. Marking it advisory makes the rule depend on a stale value again. A command cannot declare both `[ProtectedDecision]` and `[Unprotected]`, and cannot combine a command-level `[ReadProtection(ReadProtectionMode.On)]` or `Measure` with a command-level `[Unprotected]`. Arc fails the command with `CommandCannotBeBothProtectedAndUnprotected` or `ConflictingReadProtection`. [ARCCHR0029](/arc/backend/csharp/chronicle/code-analysis/arcchr0029/) reports both at build time. ## Choose the key with `[LookUpBy]` A read-model parameter reads the instance whose `Id` equals a key from the command. `[LookUpBy(nameof(Property))]` on the parameter names the command property that supplies it. It works in every mode. | Mode | Without `[LookUpBy]` | | --- | --- | | `Off`, `Measure` | The command’s event source id | | `On` | The one command property whose type equals the model’s `Id` type. If several match, `AmbiguousReadModelLookup`. If none matches, the command’s event source id | ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.Projections.ModelBound; using FluentValidation; public record AccountId(string Value) : EventSourceId(Value); [EventType] public record Deposited(decimal Amount); [EventType] public record Withdrawn(decimal Amount); public record AccountBalance( AccountId Id, [Increment, Decrement] decimal Balance); [Command] public record Transfer(AccountId From, AccountId To, decimal Amount) { public IEnumerable Handle() => [ new(From, new Withdrawn(Amount)), new(To, new Deposited(Amount)) ]; } public class TransferValidator : CommandValidator { public TransferValidator( [LookUpBy(nameof(Transfer.From))] AccountBalance? source, [LookUpBy(nameof(Transfer.To))] AccountBalance? target) { RuleFor(command => command.Amount) .Must(amount => source is not null && source.Balance >= amount) .WithMessage("The source account has insufficient funds.") .Must(amount => target is null || target.Balance + amount <= 1_000_000m) .WithMessage("The target account cannot hold that much."); } } ``` Each parameter is its own guard: one on the event source `From`, one on the event source `To`. Without the attributes, `Transfer` has two properties of the model’s `Id` type, and `AmbiguousReadModelLookup` refuses it ([ARCCHR0023](/arc/backend/csharp/chronicle/code-analysis/arcchr0023/) reports it at build time). `[LookUpBy]` names a public property of the command, including on a validator constructor parameter. Arc converts the value to the wire key; the property’s CLR type need not equal the model’s `Id` type. Concepts are unwrapped, `Guid` values are formatted as identifiers, and `EventSourceId`-derived values use their underlying key. For example, a `ConceptAs` property can select a model with a `Guid Id`, and the reverse works too. Other values use the runtime’s string conversion. Protected reads validate raw keys before conversion as described in [Missing models and invalid keys](#missing-models-and-invalid-keys). A name that does not exist, or a parameter that is not a read model, is `InvalidLookUpBy`; [ARCCHR0031](/arc/backend/csharp/chronicle/code-analysis/arcchr0031/) reports these declarations at build time. The exact-type convention in the table applies only when you omit `[LookUpBy]`. ## The model must be keyed by the entity you append to A guard covers the events of one event source. A model keyed by an event property, for example `UsingKey(e => e.Member)` over events that are appended to the book, would read facts that live on another source. Chronicle refuses it, because a conflict on the member’s events cannot be detected from the book. The fix is to append a fact to the entity the model is keyed by. To count a member’s loans, append `LoanTaken` to the member as well as `BookBorrowed` to the book, in one batch. See [two entities](/arc/backend/csharp/chronicle/consistency/scenarios/two-entities/). ## Where to go next * [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/) * [Protected reads in a generated Screenplay](/arc/backend/csharp/generating-a-screenplay/#protected-command-reads-and-esm-v25): how protected reads become `reads` and `require` in a `.play` document. * [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/): nullability, `Provide()` and the plain-read reference. * [Chronicle decision reads](/chronicle/read-models/decision-reads/): which projection shapes are admitted. # Refusals under read protection > Every error read protection can return, what causes it, whether to retry, and how to fix it. Use this page when a command fails under read protection `On` and you want to know why and what to change. Read protection fails closed: when it cannot guard a read exactly, it refuses the command instead of running it unguarded. ## Two kinds of failure | Kind | Status | What it means | What to do | | --- | --- | --- | --- | | **The read changed** | `400` | Someone wrote an event that matches what the command read, before it committed. Nothing was written | Submit the command again. The client reads fresh state and decides again | | **The code cannot be guarded** | `500` | A configuration error in the command. Retrying does not help | Change the code. The message names the read and the fix | Arc and Chronicle do not re-run the command; the fold may retry up to three times. Keep the user’s input, read fresh state, and offer a deliberate resubmission. ## Retryable: 400 | Failure | When | Message | | --- | --- | --- | | `ReadChanged` | A guarded read changed before commit | `BookStock 'isbn-1' changed after BorrowBook read it (BookBorrowed at 1042). Nothing from this command was written; submit it again.` | | `ReadIncomplete` | The fold of the read model did not reach the log position it needed after three attempts, because events arrived while it ran | `BookStock 'isbn-1' could not be read completely. Nothing was written; submit it again.` | Both arrive as a validation result with the reason `concurrencyViolation`. The detail is `ReadChanged` or `ReadIncomplete`, so a client can tell them from other concurrency violations. A `ReadChanged` result also carries the read model and key in its `state`. When Chronicle reports the first event that matched the guard after the read, the message names its event type and sequence number, and the `state` adds `conflictingEventTypeId`, `conflictingEventType` and `conflictingEventSequenceNumber`. Reads that share a guard are merged, so the event can belong to another read under the same guard. When Chronicle does not report it, the message ends at `read it.` and the `state` has only the read model and key. A `[ProtectedDecision]` command outside `On` words the message differently: `BookStock 'isbn-1' changed after it was read. Read it again and resubmit.` ## A read cannot be guarded: 500 ### Read-model parameters | Failure | Cause | Fix | | --- | --- | --- | | `ReadCannotBeProtected` | A parameter’s model has a shape Chronicle cannot guard exactly | See the reasons below | | `AmbiguousReadModelLookup` | Several command properties have the model’s `Id` type, and no `[LookUpBy]` chose one | Add `[LookUpBy(nameof(Property))]` to the parameter | | `InvalidLookUpBy` | `[LookUpBy]` names a property the command does not have, or sits on a parameter that is not a read model | Name an existing public property | `ReadCannotBeProtected` has this message. The description and the fix change with the cause: ```text LendBook reads 'stock' (BookStock) through a reducer that filters its events, so read protection cannot guard it. If it is only context, mark it [Unprotected("why")]. Otherwise remove the reducer's event filter or read an unfiltered projection. ``` | Description | Fix | | --- | --- | | through a reducer whose handler or reader implementation does not support protected replay | Use Chronicle’s built-in reducer reader with a discovered reducer handler | | through a reducer with constructor dependencies | Remove dependencies other than `ILogger`; self-reducing models are exempt from this constructor restriction | | through a reducer that filters its events | Remove the reducer’s event filter, or read an unfiltered projection | | with joins or children | Read the parts as separate models | | keyed by an event property | Append a fact to the entity the model is keyed by, or use a constraint | | using Pinned generation delivery | Use Compatibility delivery for this projection | | with an unsupported key type | Give the model a string or `Guid` `Id` | | keyed by a stream | Use `IDecisionReads.Get` with an explicit source, stream type and stream id in `Provide()` | | from another event sequence | Read a model of the event log | | without exactly one backing definition | Register exactly one projection or admitted reducer for the model | | with a mismatched registered definition | Register the current definition before executing commands | | through IMongoCollection | Inject the read model instead of its collection | Unfiltered reducers with no constructor dependencies other than `ILogger` are admitted. Self-reducing models are exempt from the constructor restriction, not from the filter restriction. [ARCCHR0024](/arc/backend/csharp/chronicle/code-analysis/arcchr0024/) reports the statically visible cases at build time. ### Other reads and appends | Failure | Cause | Fix | | --- | --- | --- | | `ReadRefusedWhileReadsAreProtected` | A read through `IReadModels`, `IEventStore`, `IEventLog` or an event sequence | Take a read model as a parameter, use `IDecisionReads.Get` in `Provide()`, or mark the method `[Unprotected("why")]` | | `ReadModelInjectedOutsideDecision` | A read model injected through dependency injection anywhere other than `Handle()` and validator constructors, for example into a command filter or a service | Take the model as a parameter of `Handle()` or of a `CommandValidator` constructor, and move the rule there. If it is only context, mark the read-model type `[Unprotected("why")]` | | `DetachedReadWhileReadsAreProtected` | `IDecisionReads.GetDetached` | Use `Get` in `Provide()` | | `ImmediateAppendWhileReadsAreProtected` | An append through `IEventLog.Append`, `AppendMany`, `Revise`, `Redact` or `CompleteStream` that writes at once | Return the event from `Handle()`, or use `IEventLog.Transactional.Append`. Return `CompleteStream` instead of calling it | | `ReadAfterProtectedCommit` | A protected read after an aggregate’s `Commit()` completed the unit | Read before committing, and return the result of `Commit()` last | | `ReadOfSourceWithStagedEvents` | A read of a source after the command staged events for it | Decide once in the outer command and pass the value down | | `ProtectedUnitOfWorkRequiresOwner` | Command code called `Commit()`, `Rollback()` or `Dispose()` on the unit | Return events. Arc owns the unit | [ARCCHR0028](/arc/backend/csharp/chronicle/code-analysis/arcchr0028/), [ARCCHR0012](/arc/backend/csharp/chronicle/code-analysis/arcchr0012/) and [ARCCHR0035](/arc/backend/csharp/chronicle/code-analysis/arcchr0035/) report the statically visible cases. An immediate append is refused for the whole invocation, including after an aggregate’s `Commit()`. An immediate append is written before the commit and stays if the commit conflicts, so it cannot be part of a guarded decision. ### Scopes and aggregates | Failure | Cause | Fix | | --- | --- | --- | | `ReadAndAggregateOnSameSource` | An aggregate and a plain read of the same event source in one command | Decide from the aggregate’s state, read another source, or mark the plain read `[Unprotected("why")]` | | `ExplicitScopeDoesNotCoverReads` | The command returns an exact concurrency scope for a source that is narrower than what it read, or captured after it | Widen the scope and capture its boundary before reading, or remove it | | `DeclaredRoutingNotCoveredByExplicitScope` | An exact scope does not cover the concurrency the event source definition declares | Widen the scope, or remove the declared concurrency | | `ConflictingContributionsOnLabel`, `ConflictingExplicitScopes` | Two different scopes or aggregates guard one event source | Keep one | | `ReadProtectionRequiresTheDefaultConcurrencyStrategy` | A custom concurrency strategy | Use the default strategy | | `ReadDependencyTargetMismatch` | A guard belongs to another event store, namespace or sequence than the unit | Read and write in the same store and namespace | | `ReservedConcurrencyScopeLabel` | A scope label starts with `$read-protection/` | Use another label | | `SourceLessReadNotSupported` | A read with no event source | Read a source-keyed model | [ARCCHR0033](/arc/backend/csharp/chronicle/code-analysis/arcchr0033/) and [ARCCHR0036](/arc/backend/csharp/chronicle/code-analysis/arcchr0036/) report the static cases of the first two rows. ### Nested commands | Failure | Cause | Fix | | --- | --- | --- | | `NestedEarlyCompletionUnderReadProtection` | A nested command committed the outer command’s unit (an aggregate `Commit()`) | Commit in the outer command | | `ConcurrentNestedCommandsUnderReadProtection` | A nested command started while another nested command of the same outer command was still running, and the outer command, the new command or any running one has read protection `On` | Run nested commands one after another | ### Declarations | Failure | Cause | Fix | | --- | --- | --- | | `CommandCannotBeBothProtectedAndUnprotected` | `[ProtectedDecision]` and `[Unprotected]` on one command | Keep one | | `ConflictingReadProtection` | `[ReadProtection(On)]` or `Measure` and `[Unprotected]` on one command | Keep one | | `DiscoverableValidatorRefusedInProtectedDecision` | A validator under `On` that Arc did not construct for this invocation | Let convention discovery register the validator | [ARCCHR0029](/arc/backend/csharp/chronicle/code-analysis/arcchr0029/) and [ARCCHR0031](/arc/backend/csharp/chronicle/code-analysis/arcchr0031/) report declaration errors at build time. ## How errors look to a client A `400` is a command result with a validation result, like any validator failure. A `500` carries the refusal text in `exceptionMessages` only when `ArcOptions.ExposeExceptionDetails` is `true`. That option defaults to `true` in Development and `false` elsewhere. When it is `false`, Arc replaces exception messages and removes stack traces; inspect server logs using the correlation id instead. Avoid exposing exception details in production. When details are exposed, Arc replaces Chronicle’s generic subject “The command” with your command’s name. See the [HTTP contract](/arc/http-contract/). Chronicle documents the same refusals at the API level in [Read protection in Chronicle](/chronicle/read-models/read-protection/#refusals-at-a-glance). ## Where to go next * [Reads](/arc/backend/csharp/chronicle/consistency/reads/): what each read becomes. * [Adopting read protection](/arc/backend/csharp/chronicle/consistency/adopting/): find refusals with `Measure` before they happen. # Scenarios for rules that hold > A catalogue of rule shapes from the simplest to the most advanced, each with a complete example, the guard it produces, and what does not hold. Use this page when you know the shape of your rule (see the [decision matrix](/arc/backend/csharp/chronicle/consistency/#decision-matrix-by-rule-shape)) and want the code. Every scenario states the rule, shows a complete example under read protection `On`, and says what holds and what does not. Each example shows its guard as one line. [How it works](/arc/backend/csharp/chronicle/consistency/how-it-works/) explains the notation. ## Fully idiomatic Arc Start here. These scenarios use `[Command]` records that return events, constraints, validators with read models, stream decisions and aggregates. | Rule | Mechanism | Page | | --- | --- | --- | | A book cannot be borrowed when no copy is left | A read model parameter and a validator | [One entity](/arc/backend/csharp/chronicle/consistency/scenarios/single-entity/) | | One library card per member. A username is unique | `[Unique]` on an event or a property | [Unique and once](/arc/backend/csharp/chronicle/consistency/scenarios/unique-and-once/) | | A member may hold three loans, and the last copy of a book | Two read models, one batch | [Two entities](/arc/backend/csharp/chronicle/consistency/scenarios/two-entities/) | | A course has capacity and a student has a limit | Two read models, two events, atomic | [Two entities](/arc/backend/csharp/chronicle/consistency/scenarios/two-entities/#a-course-and-a-student) | | At most 5000 may be withdrawn per account per month | Per-stream read model, or a stream read in `Provide()` | [One stream](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/) | | A report is approved once per owner per month | A stream decision | [One stream](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/#a-period-approval) | | Steps of a process happen in order and a finished process accepts nothing more | A stream with declared concurrency, and a closed stream | [Process stream](/arc/backend/csharp/chronicle/consistency/scenarios/process-stream/) | | Invoice numbers have no gaps and no duplicates | A numbering source read in `Provide()` | [Gapless numbers](/arc/backend/csharp/chronicle/consistency/scenarios/numbering/) | | Many rules over one lifecycle | An aggregate root | [Rich aggregate](/arc/backend/csharp/chronicle/consistency/scenarios/aggregate/) | | Do not overwrite changes the user did not see | An explicit scope with the revision | [Editing conflict](/arc/backend/csharp/chronicle/consistency/scenarios/editing-conflict/) | | A rare violation, a hint, external data, a hot key | No guard, or the right shape of data | [Rules that need no guard](/arc/backend/csharp/chronicle/consistency/scenarios/no-guard/) | ## Choose a stream or source model | Rule lives in… | Use | | --- | --- | | Folded state of one stream on the command’s own route | [Per-stream model](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/) | | The entity’s whole history, such as a balance | Whole-source model. Combining it with a per-stream model gives a whole-source guard | | “Has fact X happened in this short stream?”, without a model | Stream decision | | Rich behaviour across the whole entity | Aggregate, without reads of the same source | | Another month or another source | `IDecisionReads` stream overload in `Provide()` | | Uniqueness across sources | Constraint | ## Less idiomatic and Chronicle direct These work, and Arc supports them. Under `On` some are refused; the pages say which. | You write | Page | | --- | --- | | An injected `IEventLog`, `IReadModels` in `Handle()`, several event sources, controllers | [Less idiomatic Arc](/arc/backend/csharp/chronicle/consistency/scenarios/less-idiomatic/) | | `IEventStore`, `IEventLog` and `IDecisionReads` in endpoints, controllers and services | [Chronicle used directly](/arc/backend/csharp/chronicle/consistency/scenarios/chronicle-direct/) | ## Operating it | You want | Page | | --- | --- | | Move an existing application from `Off` to `On` | [Adopting read protection](/arc/backend/csharp/chronicle/consistency/adopting/) | | Understand and fix a refusal | [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/) | | Test that a rule holds under concurrency | [Test the race](/arc/backend/csharp/chronicle/consistency/scenarios/testing/) | ## Conventions * The command’s subject id is a type derived from `EventSourceId`, for example `public record Isbn(string Value) : EventSourceId(Value);`. Arc uses it as the event source id, so no `[Key]` is needed. * References to other entities are `ConceptAs`, unless the command appends to that entity too. * A state rule goes in a `CommandValidator` with the read model as a constructor parameter. `Handle()` returns the events. * Examples on one page share one namespace, so the types declared in an earlier example are visible to later ones. # Rich aggregate > Many rules over one lifecycle, written as an aggregate root, and how it combines with read protection. Use this when one entity has many rules and state transitions, and the rules are easier to write as methods on the entity than as separate validators over a read model. | | | | --- | --- | | Rule | A copy of a book is lent to one member at a time, and more rules about the copy will follow | | Mechanism | An aggregate root. Return the result of `Commit()` | | Alternatives | A flat read model with a validator. Several validators over one model | | Not | Ignoring the result of `Commit()`. A plain read of the same source in the same command | ```csharp using Cratis.Arc.Chronicle.Aggregates; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Concepts; public record MemberId(Guid Value) : ConceptAs(Value); public record CopyId(string Value) : EventSourceId(Value); [EventType] public record CopyLent(MemberId Member); [EventType] public record CopyReturned(); public class Copy : AggregateRoot { bool _lent; public async Task Lend(MemberId member) { if (_lent) { Failed("The copy is already lent."); return; } await Apply(new CopyLent(member)); } public void OnCopyLent(CopyLent @event) => _lent = true; public void OnCopyReturned(CopyReturned @event) => _lent = false; } [Command] public record LendCopy(CopyId Copy, MemberId Member) { public async Task Handle(Copy copy) { await copy.Lend(Member); return await copy.Commit(); } } ``` ```text guard copy: types=[CopyLent, CopyReturned], after ``` ## Under read protection | Situation | Under `On` | | --- | --- | | An aggregate and a plain read of a **different** source | Allowed. Each is its own guard | | An aggregate and a plain read of the **same** source | Refused with `ReadAndAggregateOnSameSource`. Decide from the aggregate’s state, or mark the read `[Unprotected("why")]`. [ARCCHR0033](/arc/backend/csharp/chronicle/code-analysis/arcchr0033/) reports it at build time | | `Commit()` returned last in the outermost command | Allowed. Arc commits through the owner of the unit | | A read or an append after `Commit()` in the same command | Refused. Read first, return `Commit()` last. [ARCCHR0035](/arc/backend/csharp/chronicle/code-analysis/arcchr0035/) reports it | | `Commit()` in a nested command | Refused with `NestedEarlyCompletionUnderReadProtection`. Commit in the outer command | Under `Off` the old rule holds: an aggregate cannot be combined with `[ProtectedDecision]` reads, and `Commit()` throws once a decision read is enrolled. ## What holds * An aggregate guards its own event source and the event types it handles. A new aggregate expects an empty history, so two commands that both create it cannot both succeed. * A handler that returns `Commit()` reports a rejected command as a rejection. ## What does not hold * An aggregate does not guard a rule across aggregates. Two aggregates for two keys each guard only their own source. Use [two read models](/arc/backend/csharp/chronicle/consistency/scenarios/two-entities/) for that. * An aggregate has no snapshots, so every command reads the key’s handled events again. A key with a long history gets slower. * A handler that fails after `Commit()` leaves its writes behind. That is the existing contract of an explicit commit. * A handler that awaits `Commit()` and ignores its result lets a rejected command report success. See [pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/#an-aggregate-commit-whose-result-is-ignored). ## Related * [Aggregate roots in commands](/arc/backend/csharp/chronicle/aggregates/injecting-into-commands/) * [Defining an aggregate root](/arc/backend/csharp/chronicle/aggregates/defining-an-aggregate-root/) # Chronicle used directly > Guards written with IEventStore, IEventLog and IDecisionReads in endpoints, controllers and services of an Arc application, where nothing is enrolled for you. Use this page when you write an endpoint, a controller action or a service that talks to Chronicle yourself. Chronicle gives you the same guards as inside a command. Arc’s command pipeline does not run for you, so read protection is not on: you enrol the reads, check the results and commit before you respond. | You want | Shape | | --- | --- | | A guard on one append | [A decision read on a detached append](#a-decision-read-on-a-detached-append) | | The read and the append in different places | [A unit of work with decision reads](#a-unit-of-work-with-decision-reads) | | The same in a controller | [A controller action](#a-controller-action) | | A guard from a position you captured | [An explicit scope captured before the facts](#an-explicit-scope-captured-before-the-facts) | | A value claimed once | [A value claimed through the event source id](#a-value-claimed-through-the-event-source-id) | To have Chronicle refuse unguarded reads and immediate appends in your own host code, see [Read protection in Chronicle](/chronicle/read-models/read-protection/). All examples on this page share one namespace. ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; [EventType] public record CopyAdded(); [EventType] public record BookIssued(Guid MemberId); [EventType] public record LoanTaken(string Isbn); public record BookCopies( EventSourceId Id, [Increment, Decrement] int Available); public record MemberLoans( EventSourceId Id, [Increment] int Active); ``` ## A decision read on a detached append Read a `GetDetached` decision read from the event store and pass it to `AppendMany` in `guardedBy`. ```csharp using Cratis.Chronicle; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.ReadModels; using Microsoft.AspNetCore.Http; public static class IssueBook { public static async Task Handle(IEventStore eventStore, string bookId, Guid memberId) { var stock = await eventStore.GetDecisionReads().GetDetached(bookId); if (stock.Instance is not { Available: > 0 }) { return Results.UnprocessableEntity("No copies are available."); } var result = await eventStore.EventLog.AppendMany( [new EventForEventSourceId(bookId, new BookIssued(memberId))], guardedBy: [stock]); if (result.GetDecisionConflicts([stock]).Any()) { return Results.Conflict("The book's copies changed. Nothing was written; read again and decide again."); } return result.IsSuccess ? Results.Ok() : Results.UnprocessableEntity("The loan was not recorded."); } } ``` The boundary is captured before the fold, and the append is rejected if a `CopyAdded` or `BookIssued` arrived for the book since. `AppendMany` returns a result: a violation is a value, not an exception, so always check it. Check `GetDecisionConflicts()` before you build the response, and return your own message: a `DecisionConflict` carries the read model’s `Type` and cannot be serialized as a response body. The batch must go to the event log of the same store and namespace, and needs at least one read. ## A unit of work with decision reads Use `IDecisionReads.Get` when the read and the append are in different places. The request’s unit of work enrols the read, you stage the event with `Transactional.Append`, and you commit before you respond. ```csharp using Cratis.Chronicle.AspNetCore.Transactions; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.ReadModels; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; public record TakeLoanRequest(string Isbn); public static class LoanEndpoints { public static void Map(WebApplication app) => app.MapPost("/members/{memberId}/loans", async ( string memberId, TakeLoanRequest body, HttpContext http, IDecisionReads reads, IEventLog eventLog) => { var loans = await reads.Get(memberId); if (loans.Instance is { Active: >= 3 }) { return Results.UnprocessableEntity("The member has reached the loan limit."); } await eventLog.Transactional.Append(memberId, new LoanTaken(body.Isbn)); var unit = await http.Features.Get()!.CommitAsync(); if (unit.GetDecisionConflicts().Any()) { return Results.Conflict("The member's loans changed. Nothing was written; read again and decide again."); } return unit.IsSuccess ? Results.Ok() : Results.UnprocessableEntity("The loan was not recorded."); }); } ``` Two parts matter: * `Get` needs an ambient unit of work. In an Arc app the Chronicle request middleware begins one per request. * The middleware commits *after* the endpoint returns, and reports no concurrency violation. Commit with `IUnitOfWorkCompletionFeature.CommitAsync()` before responding and use the result, or the caller sees success for a write that was rejected. Do not mix the two styles: a plain `eventLog.Append(...)` in the same request is written immediately and bypasses the guard. ## A controller action A controller uses the same calls. It is not an Arc command, so the request unit of work and your code are all there is. ```csharp using Cratis.Chronicle.AspNetCore.Transactions; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.ReadModels; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/members/{memberId}/loans")] public class MemberLoansController(IDecisionReads reads, IEventLog eventLog) : ControllerBase { [HttpPost] public async Task Take(string memberId, TakeLoanRequest body) { var loans = await reads.Get(memberId); if (loans.Instance is { Active: >= 3 }) { return UnprocessableEntity("The member has reached the loan limit."); } await eventLog.Transactional.Append(memberId, new LoanTaken(body.Isbn)); var unit = await HttpContext.Features.Get()!.CommitAsync(); if (unit.GetDecisionConflicts().Any()) { return Conflict("The member's loans changed. Nothing was written; read again and decide again."); } return unit.IsSuccess ? Ok() : UnprocessableEntity("The loan was not recorded."); } } ``` ## An explicit scope captured before the facts When no decision-read model fits, capture the tail yourself, before you read the facts, and append with a scope built from it. ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSequences.Concurrency; using Microsoft.AspNetCore.Http; using ScopeBuilder = Cratis.Chronicle.EventSequences.Concurrency.ConcurrencyScopeBuilder; [EventType] public record Deposited(decimal Amount); [EventType] public record Withdrawn(decimal Amount); public static class Withdraw { public static async Task Handle(IEventLog eventLog, EventSourceId accountId, decimal amount) { var tail = await eventLog.GetTailSequenceNumber(accountId); var events = await eventLog.GetFromSequenceNumber(EventSequenceNumber.First, accountId); var balance = events.Select(_ => _.Content).Sum(content => content switch { Deposited deposited => deposited.Amount, Withdrawn withdrawn => -withdrawn.Amount, _ => 0m }); if (balance < amount) { return Results.UnprocessableEntity("Insufficient funds."); } var scope = tail.IsUnavailable ? new ScopeBuilder().WithEventSourceId(accountId).ExpectingNoMatchingEvent().Build() : new ConcurrencyScope(tail, EventSourceId: accountId); var result = await eventLog.Append(accountId, new Withdrawn(amount), concurrencyScope: scope); return result.IsSuccess ? Results.Ok() : Results.Conflict(); } } ``` The order is the guard: the tail first, then the facts. Capture it after the facts and an event that arrived in between is invisible to the check. A `ConcurrencyScope` built without a sequence number, or with the builder but without `WithSequenceNumber` or `ExpectingNoMatchingEvent()`, is skipped by the kernel. In an Arc project, qualify Chronicle’s `ConcurrencyScopeBuilder` (here with an alias): `Cratis.Arc.Chronicle.Commands` has a static class of the same name. ## A value claimed through the event source id When a constraint does not fit, use the value as the event source id and append with an expected-empty scope for the claiming event. ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Microsoft.AspNetCore.Http; using ScopeBuilder = Cratis.Chronicle.EventSequences.Concurrency.ConcurrencyScopeBuilder; [EventType] public record UserNameClaimed(Guid UserId); public static class ClaimUserName { public static async Task Handle(IEventLog eventLog, string userName, Guid userId) { var scope = new ScopeBuilder() .WithEventSourceId(userName) .WithEventType() .ExpectingNoMatchingEvent() .Build(); var result = await eventLog.Append(userName, new UserNameClaimed(userId), concurrencyScope: scope); return result.IsSuccess ? Results.Ok() : Results.Conflict("The user name is taken."); } } ``` The scope detects any matching event appended after its boundary, whoever wrote it, but it protects only decisions that carry it: a writer that decides without a scope can still break the rule. A constraint is checked for every append, so prefer it where it fits. ## Related * [Pitfalls: results you must check](/arc/backend/csharp/chronicle/consistency/pitfalls/#results-you-must-check-when-you-use-chronicle-directly) * [Chronicle: read protection](/chronicle/read-models/read-protection/) * [Chronicle: decision reads](/chronicle/read-models/decision-reads/) # Editing conflict > Reject a change when the document changed after the editor loaded it, with an explicit scope that carries the revision the client saw. Use this when the client loaded a document, someone else may have changed it, and a save must not overwrite that change. | | | | --- | --- | | Rule | A rename is rejected if the document changed after the editor loaded it | | Mechanism | An explicit scope built from the revision the client sent, returned with `EventsWithConcurrencyScopes` | | Alternatives | A read model parameter when the conflict only matters within one request | | Not | `concurrency: true` alone: it reads the tail after `Handle()`, so it cannot carry the revision the user saw | ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSequences.Concurrency; public record DocumentId(string Value) : EventSourceId(Value); [EventType] public record DocumentTitleChanged(string Title); [Command] public record RenameDocument(DocumentId Document, string Title, ulong ExpectedRevision) { public EventsWithConcurrencyScopes Handle() => new( [new(Document, new DocumentTitleChanged(Title))], [new(Document, new ConcurrencyScope( new EventSequenceNumber(ExpectedRevision), EventSourceId: Document, EventTypes: [typeof(DocumentTitleChanged).GetEventType()]))]); } ``` ```text guard document: types=[DocumentTitleChanged], after ``` The scope fails the append if any `DocumentTitleChanged` for this document has a sequence number above `ExpectedRevision`. How the client learns the revision is part of your read model and query. ## Under read protection If the command also reads a model of the same document, the scope must cover what the read watches and must not have been captured after it. Otherwise the command is refused with `ExplicitScopeDoesNotCoverReads`. The scope above covers a read of `DocumentTitleChanged` events. A read model that folds more types than the scope names needs a wider scope. [ARCCHR0036](/arc/backend/csharp/chronicle/code-analysis/arcchr0036/) reports it at build time. ## Related * [Concurrency](/arc/backend/csharp/chronicle/commands/concurrency/#carrying-the-revision-used-by-the-decision) * [Chronicle: explicit scopes](/chronicle/events/concurrency/) # Less idiomatic Arc > Shapes that work in Arc but are not guarded by themselves, such as an injected IEventLog or several event sources, and how to return each to the idiomatic form. Use this page when your command is not a plain `[Command]` that returns events. These shapes work, and Arc supports them. Under read protection `On`, some are refused. Each section says what the shape does and how to move it to the [idiomatic form](/arc/backend/csharp/chronicle/consistency/scenarios/#fully-idiomatic-arc). All examples on this page share one namespace. ```csharp using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; public record BookId(string Value) : EventSourceId(Value); public record MemberId(string Value) : EventSourceId(Value); [EventType] public record CopyAdded(); [EventType] public record BookBorrowed(MemberId Member); [EventType] public record MemberBorrowedBook(BookId Book); [EventType] public record ReadingImported(double Value); public record BookStock( EventSourceId Id, [Increment, Decrement] int Available); ``` ## An injected event log Injecting `IEventLog` and calling `Append` writes at once. The append reads the tail when you call it, ignores the command’s unit of work and any guard, and stays in the log if the command later fails. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; public record SensorId(string Value) : EventSourceId(Value); [Command] public record ImportReading(SensorId Sensor, double Value) { public async Task Handle(IEventLog eventLog) => await eventLog.Append(Sensor, new ReadingImported(Value)); } ``` | Mode | What happens | | --- | --- | | `Off`, `Measure` | Written at once. A successful append cannot be undone. [ARCCHR0007](/arc/backend/csharp/chronicle/code-analysis/arcchr0007/) reports the `IEventLog` parameter | | `On` | Refused with `ImmediateAppendWhileReadsAreProtected`. [ARCCHR0012](/arc/backend/csharp/chronicle/code-analysis/arcchr0012/) reports it as a warning | The idiomatic form returns the event: `public ReadingImported Handle() => new(Value);`. If you must append inside `Handle()`, stage the event in the command’s unit of work with `eventLog.Transactional.Append(...)`. It commits in one atomic append with the events you return and under the same guards: ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using FluentValidation; [Command] public record BorrowBook(BookId Book, MemberId Member) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => Book; public async Task Handle(IEventLog eventLog) { await eventLog.Transactional.Append(Member, new MemberBorrowedBook(Book)); return new BookBorrowed(Member); } } public class BorrowBookValidator : CommandValidator { public BorrowBookValidator(BookStock? stock) => RuleFor(command => command.Book) .Must(_ => stock is { Available: > 0 }) .WithMessage("No copies of this book are available."); } ``` [ARCCHR0007](/arc/backend/csharp/chronicle/code-analysis/arcchr0007/) still reports the `IEventLog` parameter, because the rule matches the parameter type and not the call. A narrow suppression is acceptable for a deliberate transactional append. Outside a command or another established unit of work, `Transactional.Append` throws. ## Plain read models through IReadModels `IReadModels.GetInstanceById(...)` returns state and captures no boundary. Under `Off` it works and guards nothing: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Chronicle.Events; using Cratis.Chronicle.ReadModels; using Cratis.Monads; [Command] public record LendBook(BookId Book, MemberId Member) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => Book; public async Task> Handle(IReadModels readModels) { var stock = await readModels.GetInstanceById(Book); return stock is not { Available: > 0 } ? ValidationResult.Error("No copies of this book are available.") : new BookBorrowed(Member); } } ``` | Mode | What happens | | --- | --- | | `Off` | Two commands can both read `Available: 1` and both succeed. The projection may also lag the log. [ARCCHR0011](/arc/backend/csharp/chronicle/code-analysis/arcchr0011/) reports it at Info severity | | `Measure` | Reported as `refused_read` | | `On` | Refused with `ReadRefusedWhileReadsAreProtected`. [ARCCHR0028](/arc/backend/csharp/chronicle/code-analysis/arcchr0028/) reports it | Take the model as a parameter instead: a validator with `BookStock?`, as in [one entity](/arc/backend/csharp/chronicle/consistency/scenarios/single-entity/). If the read is deliberately a hint, mark the method `[Unprotected("why")]` and enforce the rule elsewhere. ## The earlier form: \[ProtectedDecision] and DecisionRead Before read protection had modes, a command opted in one at a time with `[ProtectedDecision]` and took `DecisionRead`. That form still works. Under `On` you can use plain read-model parameters instead; `On` also enables protected validator constructor reads. ```csharp using Cratis.Arc.Chronicle.ReadModels; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Chronicle.Events; using Cratis.Chronicle.ReadModels; using Cratis.Monads; [Command] [ProtectedDecision] public record BorrowBookDecision(BookId Book, MemberId Member) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => Book; public Result Handle(DecisionRead stock) => stock.Instance is not { Available: > 0 } ? ValidationResult.Error("No copies of this book are available.") : new BookBorrowed(Member); } ``` Use it when a project stays `Off` and one command needs a guard now. Move to a plain parameter and a validator when you turn the project `On`: a `[ProtectedDecision]` command outside `On` refuses validators with constructor dependencies, and a validator is where the rule belongs. See [Validators](/arc/backend/csharp/chronicle/consistency/validators/#commands-marked-protecteddecision). ## Several event sources Returning `EventForEventSourceId` values writes to several event sources in one atomic append. Without reads or explicit scopes, each source gets the default check taken at commit. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; public record CourseId(string Value) : EventSourceId(Value); [EventType] public record StudentEnrolled(CourseId Course, MemberId Student); [Command] public record EnrollStudent(CourseId Course, MemberId Student) { public IEnumerable Handle() { var enrolled = new StudentEnrolled(Course, Student); return [new(Course, enrolled), new(Student, enrolled)]; } } ``` The batch is atomic: either both events are written or neither. Nothing here checks a capacity. To add the rule, read both keys as in [two entities](/arc/backend/csharp/chronicle/consistency/scenarios/two-entities/). Aggregates for each key do not give you that: each aggregate guards only its own source. ## Controllers An MVC controller action is not a model-bound `[Command]`. It does not get Arc’s command unit of work, so read protection does not apply. Write it as in [Chronicle used directly](/arc/backend/csharp/chronicle/consistency/scenarios/chronicle-direct/#a-controller-action). # Rules that need no guard > When a rare violation is acceptable, a hint is enough, the data is external, or the key has a long history, and what to do instead of a guard. Use this page before you add a guard to a rule that does not need one. Each section says what you give up. ## A rare violation you can repair | | | | --- | --- | | Rule | A flight may be overbooked briefly. A reactor cancels the reservations that exceed the capacity | | Mechanism | The default append, and a reactor that appends a correcting event | | What you give up | Between the violation and the repair the data is wrong. A repair cannot undo an irreversible side effect | ```csharp using Cratis.Chronicle; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.Reactors; [EventType] public record SeatReserved(Guid ReservationId); [EventType] public record ReservationCancelled(Guid ReservationId, string Reason); public class OverbookingRepair(IEventStore eventStore) : IReactor { const int Capacity = 100; [OnceOnly] public async Task Reserved(SeatReserved @event, EventContext context) { var events = await eventStore.EventLog.GetFromSequenceNumber( EventSequenceNumber.First, context.EventSourceId); if (events.Any(_ => _.Content is ReservationCancelled cancelled && cancelled.ReservationId == @event.ReservationId)) { return null; } var upToThis = events.Where(_ => _.Context.SequenceNumber <= context.SequenceNumber).ToList(); var active = upToThis.Count(_ => _.Content is SeatReserved) - upToThis.Count(_ => _.Content is ReservationCancelled); return active > Capacity ? new ReservationCancelled(@event.ReservationId, "The flight was overbooked.") : null; } } ``` Returning `null` records nothing. `[OnceOnly]` keeps the repair out of replays. It does not make delivery exactly-once, so the reactor skips a reservation it already cancelled, and it counts cancellations so a seat freed earlier is not held against a later reservation. A reactor is not a command, so it is not under read protection. Its own reads go through `IEventStore` and are not refused. ## A hint | | | | --- | --- | | Rule | Show “no copies left” before the user submits | | Mechanism | A query, or a validator over a read marked `[Unprotected("why")]` | | What you give up | The hint can be stale. The rule is enforced elsewhere | ```csharp using Cratis.Arc.Chronicle.ReadModels; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; using FluentValidation; public record Isbn(string Value) : EventSourceId(Value); [EventType] public record BookReserved(); public record Reservations( EventSourceId Id, [Increment] int Count); [Command] public record ReserveBook(Isbn Isbn) { public BookReserved Handle() => new(); } public class ReserveBookValidator : CommandValidator { public ReserveBookValidator([Unprotected("hint only; the reservation limit is a constraint")] Reservations? reservations) => RuleFor(command => command.Isbn) .Must(_ => reservations is not { Count: >= 10 }) .WithMessage("This book has too many reservations."); } ``` The reason is the part a reviewer reads. State what holds the rule instead. ## External data | | | | --- | --- | | Rule | A purchase uses the current price, and a book can be bought only while a copy is available | | Mechanism | `Provide()` fetches the price. A read model parameter guards your own state. The price goes into the event | | What you give up | The external value is not guarded. The event records what the decision used | ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; using FluentValidation; public interface IPriceList { Task PriceOf(EventSourceId bookId); } public record QuotedPrice(decimal Amount); public record BookId(string Value) : EventSourceId(Value); [EventType] public record CopyStocked(); [EventType] public record CopySold(decimal Price); public record CopiesInStock( EventSourceId Id, [Increment, Decrement] int Available); [Command] public record SellCopy(BookId Book) { public async Task Provide(IPriceList prices) => new(await prices.PriceOf(Book)); public CopySold Handle(QuotedPrice price) => new(price.Amount); } public class SellCopyValidator : CommandValidator { public SellCopyValidator(CopiesInStock? stock) => RuleFor(command => command.Book) .Must(_ => stock is { Available: > 0 }) .WithMessage("No copies are left."); } ``` `IPriceList` is an unknown dependency. Read protection guards the stock and nothing about the price. ## A hot key with a long history | | | | --- | --- | | Rule | A room accepts at most 20 bookings a month | | Mechanism | Key the model by the period that bounds the rule, not by the room | | What you give up | Nothing. A guarded read folds the key’s history every time, so keep it short | ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; using FluentValidation; [EventType] public record RoomBooked(Guid RoomId, DateOnly Day); public record RoomMonth(EventSourceId Id, [Increment] int Bookings); [Command] public record BookRoom(Guid RoomId, DateOnly Day) : ICanProvideEventSourceId { public EventSourceId GetEventSourceId() => $"{RoomId:D}:{Day:yyyy-MM}"; public RoomBooked Handle() => new(RoomId, Day); } public class BookRoomValidator : CommandValidator { public const int MaximumBookingsPerMonth = 20; public BookRoomValidator(RoomMonth? month) => RuleFor(command => command.Day) .Must(_ => month is not { Bookings: >= MaximumBookingsPerMonth }) .WithMessage("The room is fully booked this month."); } ``` Each month is a new event source, so each decision folds at most one month of bookings. The key must not be blank or `*`, must be trimmed, and must not contain `#`. ## Related * [Reads](/arc/backend/csharp/chronicle/consistency/reads/#declare-an-advisory-read) * [Pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/) # Gapless numbers > Numbers that have no gaps and no duplicates across all entities, written as a numbering source that is read in Provide. Use this when a number must be unique and must not skip, across every entity: invoice numbers, case numbers. | | | | --- | --- | | Rule | Invoice numbers have no gaps and no duplicates across all invoices | | Mechanism | A numbering event source (`"invoice-numbers"`) with a projection of the next number, read with `IDecisionReads` in `Provide()`. The invoice carries the number | | Alternatives | `[Unique]` on the number: no duplicates, but gaps are possible | | Not | A declared `Concurrency` without an event source id: it is not anchored to a read | ```csharp using Cratis.Arc.Chronicle.ReadModels; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.Projections.ModelBound; using Cratis.Chronicle.ReadModels; using Cratis.Monads; [EventType] public record InvoiceNumberAssigned(string Invoice); public record NextInvoiceNumber( EventSourceId Id, [Increment] int Assigned); [EventType] public record InvoiceIssued(int Number); public record InvoiceId(string Value) : EventSourceId(Value); [Command] public record IssueInvoice(InvoiceId Invoice) { public static readonly EventSourceId Numbering = "invoice-numbers"; public async Task Provide(IDecisionReads reads) { var read = await reads.Get(Numbering.Value); return new((read.Instance?.Assigned ?? 0) + 1); } public IEnumerable Handle(NumberAssignment number) => [ new(Numbering, new InvoiceNumberAssigned(Invoice.Value)), new(Invoice, new InvoiceIssued(number.Number)) ]; } public record NumberAssignment(int Number); ``` ```text guard invoice-numbers: types=[InvoiceNumberAssigned], after ``` The numbering source is read and then appended to in the same atomic batch. If another invoice took the next number between the read and the commit, nothing is written and the caller submits again. The batch also records `InvoiceIssued` on the invoice, so the number is on both sides. ## What holds * No two invoices get the same number, and a number is not skipped, because every issue appends to the numbering source after reading it. * A failed issue writes nothing, so no number is consumed. ## What does not hold * A nested command that reads the numbering source after the outer command already staged `InvoiceNumberAssigned` is refused with `ReadOfSourceWithStagedEvents`: the read cannot see the staged event. Decide once in the outer command and pass the value down. * All invoices serialize on the numbering source. That is the cost of a gapless sequence. If gaps are acceptable, use `[Unique]` on the number. ## Related * [Reads](/arc/backend/csharp/chronicle/consistency/reads/) * [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/#other-reads-and-appends) # One stream > Enforce a monthly limit with a per-stream read model and a validator, or check raw facts with a stream decision. Use a per-stream read model when a rule depends on the folded state of one stream on the command’s own route. October’s withdrawals should not make November’s command conflict. Declare the model’s stream, take it as a plain parameter, and put the rule in a validator. Under `On`, Arc guards that stream. ## Choose the boundary | Rule lives in… | Use | | --- | --- | | Folded state of one stream on the command’s own route | Per-stream model | | The entity’s whole history, such as a balance | Whole-source model. Combining it with a per-stream model gives a whole-source guard | | “Has fact X happened in this short stream?”, without a model | Stream decision | | Rich behaviour across the whole entity | Aggregate. Do not also read a model of the same source | | Another month or another source | `IDecisionReads` stream overload in `Provide()` | | Uniqueness across sources | Constraint | ## A monthly limit | | | | --- | --- | | Rule | At most 5000 may be withdrawn per account per month | | Mechanism | A per-stream model and a `CommandValidator` under `On` | | Alternatives | A stream decision for short raw-event checks; a whole-source model when every month must count | | Not | A key-only read of a per-stream model: Chronicle refuses it | ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Queries.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSources; using Cratis.Chronicle.Projections.ModelBound; using Cratis.Chronicle.ReadModels; using FluentValidation; [EventSource("BankAccount")] [EventStream("Transactions")] public class BankAccountEventSource : IEventSource; public record AccountId(string Value) : EventSourceId(Value); [EventType] public record MoneyWithdrawn(decimal Amount); [ReadModel, EventStream("Transactions")] public record MonthlyWithdrawals( AccountId Id, [AddFrom(nameof(MoneyWithdrawn.Amount))] decimal Total) { public static async Task ForMonth(AccountId account, EventStreamId month, IReadModels models) { var instance = await models.GetInstanceById(account, month); return instance is null ? null : await models.Release(instance); } } [Command, ReadProtection(ReadProtectionMode.On)] [EventSource("Transactions")] [EventStreamId("{Month}")] public record Withdraw(AccountId Account, string Month, decimal Amount) { public MoneyWithdrawn Handle() => new(Amount); } public class WithdrawValidator : CommandValidator { public WithdrawValidator(MonthlyWithdrawals? month) => RuleFor(command => command.Amount) .Must(amount => (month?.Total ?? 0m) + amount <= 5000m) .WithMessage("Monthly limit reached"); } ``` The model keeps the account as its `Id`; the month selects the fold, not a new model key. Chronicle makes the model passive, so a stored instance never mixes months. Arc resolves `{Month}` before constructing the validator. A missing or blank `Month` returns `400` naming that property, without reading. The validator rejects an exceeded limit; `Handle()` only returns the event. ```text guard account: types=[MoneyWithdrawn], sourceType=BankAccount, streamType=Transactions, streamId=2026-10, after ``` If another withdrawal arrives in October after the validator’s read, commit returns `400`, `concurrencyViolation`, `ReadChanged`, and writes nothing. November activity does not conflict. Validator and `Handle()` parameters of the same model share one enrolled fold and released instance. A cached read is still checked when it is enrolled again. Under `Off` and `Measure`, the same parameter folds only that month but supplies no guard. Measure reports the stream dimensions and `coarsened` when another read drops them. Existing models without the stream declaration keep their existing behavior. ### Query one month `ForMonth` is a model-bound query on the `[ReadModel]` record above. It calls the plain stream overload, which takes the source and stream types from the declaration. It returns October for that account, not a stored whole-account total. It guards nothing. Do not use a key-only lookup, collection read or `Watch` for this model; Chronicle refuses them. ### Keep the route exact * The command needs a matching source type and stream type, and an explicit, non-default stream id. `[EventSource("Transactions")]` keeps writers aligned. Matching string routes work too. * Another definition, stream, `All`, or a missing stream id is refused in every mode with `ReadModelStreamNotOnCommandRoute`. [ARCCHR0037](/arc/backend/csharp/chronicle/code-analysis/arcchr0037/) catches source-visible mismatches. * Do not put `[LookUpBy]` on a per-stream parameter. The source always comes from the command’s route. Read another account or month explicitly through `IDecisionReads` in `Provide()`; see [stream reads](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/#read-a-projection-within-one-stream). * A whole-source read on the same account chooses a whole-source guard. Another stream read can widen it too. Declared `Concurrency` that omits stream dimensions can also widen it; [ARCCHR0038](/arc/backend/csharp/chronicle/code-analysis/arcchr0038/) reports source-visible cases. Use no declared policy for the model’s exact event types, or stream-level `EventSourceId | EventStreamType | EventStreamId` for an any-event stream boundary. * The declaration’s source type filters both fold and guard. Events written without that source type are not included. Every writer must use the same definition route. Revisions and redactions remain outside the guard. ## A period approval Use a stream decision for the raw question “has this report been approved in this short stream?”. Use a per-stream model when you need an approval count or folded status; the [period approval model](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/#per-stream-approval-state) shows that alternative. ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Chronicle.Streams; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSources; [EventSource("Report")] [EventStream("Approval", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)] public class ReportEventSource : IEventSource; public record ReportId(string Value) : EventSourceId(Value); [EventType] public record ReportApproved; [Command] [EventSource("Approval")] [EventStreamId("{Owner}:{Month}")] public record ApproveReport(ReportId Report, string Owner, string Month) { public Task Provide(IStreamReads reads) => reads.ForCommand(); public EventsWithConcurrencyScopes Handle(StreamDecision decision) => decision.Has() ? decision.Append([]) : decision.Append(new ReportApproved()); } ``` The decision reads and guards `Approval/owner-1:2026-10` for this report. Any competing event in that stream after the read refuses the append, including the first append. Another month does not conflict. The whole stream crosses the wire, so keep it short. The empty append is an identical retry: approval was already recorded, so nothing is written or checked again. Choose a stream decision or a per-stream validator, not both. The validator reads before `Provide()`; a later stream-decision boundary may not cover it and is refused with `ExplicitScopeDoesNotCoverReads`. [ARCCHR0036](/arc/backend/csharp/chronicle/code-analysis/arcchr0036/) warns about that combination. An aggregate plus a per-stream read of the same source is refused too. ## Related * [Process streams and ordered onboarding](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/#per-stream-onboarding-state) * [Stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/) * [Chronicle: per-stream models](/chronicle/read-models/per-stream/) # Process stream > A process with ordered steps and an end, written as a stream per process, a stream decision for the order, and a completed stream for the end. Use this when each process (an onboarding, an order fulfilment) has its own history, the steps must happen in order, and a finished process must accept nothing more. | | | | --- | --- | | Rule | Onboarding steps happen in order. A finished onboarding accepts nothing more | | Mechanism | A definition with an `Onboarding` stream per process. A stream decision for the order. `CompleteStream` for the end | | Alternatives | An aggregate for each process | | Not | A plain read of a status field. A declared `Concurrency` as the only rule: it is the default append check, not a decision guard | ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Chronicle.Streams; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSources; using Cratis.Monads; [EventSource("Customer", Concurrency = ConcurrencyDimensions.EventSourceId)] [EventStream("Onboarding", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)] public class CustomerEventSource : IEventSource; public record CustomerId(string Value) : EventSourceId(Value); [EventType] public record OnboardingStepCompleted(string Step); [EventType] public record OnboardingFinished; [Command] [EventSource("Onboarding")] [EventStreamId("{ProcessId}")] public record CompleteOnboardingStep(CustomerId Customer, string ProcessId, string Step) { static readonly string[] _steps = ["Identity", "Address", "Consent"]; public Task Provide(IStreamReads reads) => reads.ForCommand(); public Result Handle(StreamDecision decision) { var done = decision.EventsOf().Count(); return done < _steps.Length && _steps[done] == Step ? decision.Append(new OnboardingStepCompleted(Step)) : ValidationResult.Error($"The next step is '{(done < _steps.Length ? _steps[done] : "none")}'."); } } [Command] [EventSource("Onboarding")] [EventStreamId("{ProcessId}")] public record FinishOnboarding(CustomerId Customer, string ProcessId) { public (OnboardingFinished, CompleteStream) Handle() => (new(), new()); } ``` ```text guard customer: any type, streamType=Onboarding, streamId=, after ``` ## What holds * Two steps submitted at the same time: the second one conflicts, because the first appended to the stream after the second read it. Nothing is written for the loser. * After `FinishOnboarding`, Arc completes the stream once the command’s events commit. A later append to that stream, from any command, fails with a validation result that names the closed stream. Another customer’s process with the same id stays open. * The guard is the whole stream, on purpose. A process stream is short and every event in it matters to the next decision. ## What does not hold * Appending and completing are separate operations. If the process stops in between, the final event stays and the stream can stay open. Retrying only the completion is safe. * A stream that grows without end makes every step read more. Keep one stream for one process. * Declaring `Concurrency` on a stream does not make a rule hold. It makes the default append check wider. It is the right setting here because the stream decision, not the declaration, carries the rule. Declare it only for ledgers, numbering and process streams. ## Related * [Completing streams](/arc/backend/csharp/chronicle/commands/completing-streams/) * [Stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/) * [A stream per process](/arc/backend/csharp/chronicle/commands/event-source-definition-scenarios/#a-stream-per-process) # One entity > A rule over the state of one entity, such as stock or a count, written as a read model parameter and a validator. Use this when a command may proceed only if the stored state of one entity allows it: the last copy of a book, a limit on one member, a balance. | | | | --- | --- | | Rule | A book cannot be borrowed when no copy is left | | Mechanism | A read model parameter under `On`, the rule in a validator | | Alternatives | An aggregate (more code, no read model). `IDecisionReads` in `Provide()` for a computed key | | Not | A validator over a plain read under `Off`. It is only a hint | ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; using Cratis.Concepts; using FluentValidation; public record Isbn(string Value) : EventSourceId(Value); public record MemberId(Guid Value) : ConceptAs(Value); [EventType] public record BookAdded(); [EventType] public record BookBorrowed(MemberId Member); [EventType] public record BookReturned(MemberId Member); public record BookStock( EventSourceId Id, [Increment, Decrement, Increment] int Available); [Command] public record BorrowBook(Isbn Isbn, MemberId Member) { public BookBorrowed Handle() => new(Member); } public class BorrowBookValidator : CommandValidator { public BorrowBookValidator(BookStock? stock) => RuleFor(command => command.Isbn) .Must(_ => stock is { Available: > 0 }) .WithMessage("No copies left."); } ``` ```text guard isbn: types=[BookAdded, BookBorrowed, BookReturned], after ``` ## What holds * Two members who borrow the last copy at the same time cannot both succeed. The second gets a `400` with the reason `concurrencyViolation` and the message `BookStock 'isbn' changed after BorrowBook read it (BookBorrowed at 1042). Nothing from this command was written; submit it again.` * Only the model’s event types conflict. A `BookRenamed` for the same book does not. * A book that has no events yet reads as `null`, and the rule above rejects it. ## What does not hold * The guard covers one key. A rule over several books needs a model that folds all of them, or two reads. See [two entities](/arc/backend/csharp/chronicle/consistency/scenarios/two-entities/). * A rejected command writes nothing, so the client must read again before it tries again. Arc never resubmits. ## Related * [Validators](/arc/backend/csharp/chronicle/consistency/validators/) * [Getting started](/arc/backend/csharp/chronicle/consistency/getting-started/) * [Chronicle: decision reads](/chronicle/read-models/decision-reads/) # Test the race > Test that a rule holds when two writers race, with a command scenario that queues a competing append after the command has read its state. Use this page when you want a test that proves a rule holds under concurrency: the command reads, a competitor writes, and the command must not commit. | You want to test | Use | | --- | --- | | A guarded read changed before commit | `new CommandScenario()`, `Given.ForEventSource(...)`, `AppendConcurrently(...)` | | An explicit scope or a stream decision | The same, with a route for a stream | | A validator’s rule | The same scenario. The validator reads the same log | | A plain `Off` command without protected dependencies | `new CommandScenario()`. Pinned read models stand in for reads | The scenario automatically selects one real in-process event log for commands with read protection `On` or `Measure`, protected decisions, decision dependencies, per-stream read models or guarded generated parameters. Seeded events, guarded reads and commits see the same history. Pinned read models cannot supply a guard, so seed real events instead. ## A race on one entity ```csharp using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Testing.Commands; using Cratis.Arc.Validation; using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; using Cratis.Concepts; using FluentValidation; using Xunit; public record Isbn(string Value) : EventSourceId(Value); public record MemberId(Guid Value) : ConceptAs(Value); [EventType] public record BookAdded(); [EventType] public record BookBorrowed(MemberId Member); public record BookStock( EventSourceId Id, [Increment, Decrement] int Available); [Command, ReadProtection(ReadProtectionMode.On)] public record BorrowBook(Isbn Isbn, MemberId Member) { public BookBorrowed Handle() => new(Member); } public class BorrowBookValidator : CommandValidator { public BorrowBookValidator(BookStock? stock) => RuleFor(command => command.Isbn) .Must(_ => stock is { Available: > 0 }) .WithMessage("No copies left."); } public class when_borrowing_the_last_copy_while_another_member_borrows_it { [Fact] public async Task should_reject_the_command_with_a_concurrency_violation() { await using var scenario = new CommandScenario(); var isbn = new Isbn("978-0-00-000001-0"); scenario.Given.ForEventSource(isbn).Events(new BookAdded()); scenario.AppendConcurrently(isbn, new BookBorrowed(new MemberId(Guid.NewGuid()))); var result = await scenario.Execute(new BorrowBook(isbn, new MemberId(Guid.NewGuid()))); result.ShouldHaveValidationErrorBecauseOf(ValidationResultReason.ConcurrencyViolation); Assert.Empty(scenario.AppendedEvents); } } ``` `AppendConcurrently` appends after the command read its state and before it commits. The command’s own event is not written, so the scenario records no appended events for it. The read protection mode comes from the assembly that declares the command, or from `[ReadProtection(...)]` on it, as in production. Test the command in the mode it runs in. ## Other checks | Check | How | | --- | --- | | That a command with `Off` still behaves | Run the scenario in a project that is `Off`. It exercises plain reads | | That a stream decision guards a stream | Seed and compete on the same route. See [Stream decisions](/arc/backend/csharp/chronicle/commands/stream-decisions/#seed-and-interfere-on-the-same-route) | | That Chronicle admits a read model before production | [Check that Chronicle admits the decision reads](/arc/backend/csharp/testing/chronicle/#check-that-chronicle-admits-the-decision-reads) | `DecisionReadAdmissions.ShouldAdmitDecisionReadsOf` and `ShouldAdmitDecisionReadsIn` check `[ProtectedDecision]` commands and commands declaring `On` on the command or assembly, ignoring the runtime cap. Under `On`, they include plain Chronicle read-model parameters and discoverable validator constructor parameters, except advisory reads. Per-stream model parameters use stream admission. Calls inside method bodies still need a scenario or an explicit admission check. For the rest of the scenario API, see [Testing Chronicle commands](/arc/backend/csharp/testing/chronicle/). ## Related * [Getting started](/arc/backend/csharp/chronicle/consistency/getting-started/) * [Chronicle: test the race](/chronicle/dynamic-consistency-boundary/scenarios/) # Two entities > A rule over two entities at once, written as two read model parameters in a validator and one batch with one event for each entity. Use this when one rule depends on the state of two entities: a member’s loan limit and a book’s last copy, or a course’s capacity and a student’s limit. | | | | --- | --- | | Rule | A member may hold three loans, and a book cannot be borrowed when no copy is left | | Mechanism | Two validator constructor parameters, each with `[LookUpBy]`, and one event for each entity in one atomic batch | | Alternatives | `IDecisionReads` in `Provide()` when the second key is computed. `Handle()` returning a `Result` when the rule needs a value computed there. Explicit scopes in one batch (Chronicle used directly) | | Not | Two aggregates, each guarding only its own source. One model that folds both entities: it is refused | ## A member and a book ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.Projections.ModelBound; using FluentValidation; public record Isbn(string Value) : EventSourceId(Value); public record MemberId(string Value) : EventSourceId(Value); [EventType] public record BookAdded(); [EventType] public record BookBorrowed(MemberId Member); [EventType] public record LoanTaken(Isbn Book); [EventType] public record LoanReturned(Isbn Book); public record BookStock( EventSourceId Id, [Increment, Decrement] int Available); public record MemberLoans( EventSourceId Id, [Increment, Decrement] int Active); [Command] public record BorrowBook(Isbn Isbn, MemberId Member) { public IEnumerable Handle() => [ new(Isbn, new BookBorrowed(Member)), new(Member, new LoanTaken(Isbn)) ]; } public class BorrowBookValidator : CommandValidator { public BorrowBookValidator( [LookUpBy(nameof(BorrowBook.Isbn))] BookStock? stock, [LookUpBy(nameof(BorrowBook.Member))] MemberLoans? loans) { RuleFor(command => command.Isbn) .Must(_ => stock is { Available: > 0 }) .WithMessage("No copies left."); RuleFor(command => command.Member) .Must(_ => loans is null || loans.Active < 3) .WithMessage("The member has reached the loan limit."); } } ``` ```text guard isbn: types=[BookAdded, BookBorrowed], after guard member: types=[LoanTaken, LoanReturned], after ``` The validator reads and checks both entities. `Handle()` says only what happens. Both guards are checked in the one atomic append. If either side changed, nothing is written and the message names the model that changed. Things to get right: * Each `EventForEventSourceId` carries its own destination. No `ICanProvideEventSourceId` is needed, including when `Handle()` returns these values inside a `Result` or a `Task` ([ARCCHR0002](/arc/backend/csharp/chronicle/code-analysis/arcchr0002/)). * Each entity gets an event of its own type. `BookBorrowed` goes to the book and `LoanTaken` goes to the member. Do not write the same event type to both: each model would fold the other entity’s copy. * The model of each entity must be keyed by that entity and folded from events appended to it. `MemberLoans` counts `LoanTaken` on the member’s own stream. * `[LookUpBy]` names a property of the command, even on a validator constructor. Arc converts its value to the wire key: an `Isbn` can select a model with an `EventSourceId Id`. The CLR types need not be identical. ### The trap: a model keyed by an event property It is tempting to build `MemberLoans` from the book’s events, keyed by the member named inside the event (`UsingKey(e => e.Member)`). Chronicle refuses that model, because a conflict on the member cannot be detected from the book’s stream. The command fails with: ```text BorrowBook reads 'loans' (MemberLoans) keyed by an event property, so read protection cannot guard it. If it is only context, mark it [Unprotected("why")]. Otherwise append a fact to the entity it is keyed by, or use a constraint. ``` The fix is the example above: append `LoanTaken` to the member. ## A course and a student Course capacity and a per-student limit use the same pattern. ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.Projections.ModelBound; using FluentValidation; public record CourseId(string Value) : EventSourceId(Value); public record StudentId(string Value) : EventSourceId(Value); [EventType] public record CourseDefined(int Capacity); [EventType] public record SeatTaken(StudentId Student); [EventType] public record CourseJoined(CourseId Course); [FromEvent] public record CourseSeats( EventSourceId Id, int Capacity, [Increment] int Taken); public record StudentCourses( EventSourceId Id, [Increment] int Count); [Command] public record SubscribeToCourse(CourseId Course, StudentId Student) { public IEnumerable Handle() => [ new(Course, new SeatTaken(Student)), new(Student, new CourseJoined(Course)) ]; } public class SubscribeToCourseValidator : CommandValidator { public SubscribeToCourseValidator( [LookUpBy(nameof(SubscribeToCourse.Course))] CourseSeats? course, [LookUpBy(nameof(SubscribeToCourse.Student))] StudentCourses? student) { RuleFor(command => command.Course) .Must(_ => course is not null) .WithMessage("The course does not exist.") .Must(_ => course is null || course.Taken < course.Capacity) .WithMessage("The course is full."); RuleFor(command => command.Student) .Must(_ => student is null || student.Count < 3) .WithMessage("The student is already in three courses."); } } ``` ```text guard course: types=[CourseDefined, SeatTaken], after guard student: types=[CourseJoined], after ``` A decision on a key guards that key even when the command writes only to another one. A competing subscription for the student conflicts with this command. Chronicle writes one event for each entity. A model of the Dynamic Consistency Boundary would write one event that carries both tags; Chronicle has no tags in a guard, so the fact is two events in one atomic append. See [Chronicle: when one fact becomes two events](/chronicle/dynamic-consistency-boundary/chronicle/#when-one-fact-becomes-two-events). ## What does not hold This guards exactly the keys you read. If the set of keys can grow, for example a limit across loans that each live on their own book stream, a new key enters nobody’s guard. Model such a limit on one key that carries all the facts, as `MemberLoans` does. ## Related * [Reads](/arc/backend/csharp/chronicle/consistency/reads/#choose-the-key-with-lookupby) * [Chronicle: cross-entity rules](/chronicle/dynamic-consistency-boundary/scenarios/) # Unique And Once > Use this when the rule is that a value is used once, or that something happens once per entity. A constraint is the right tool. It needs no read and no… Use this when the rule is that a value is used once, or that something happens once per entity. A constraint is the right tool. It needs no read and no read protection, and it holds for every writer. | | | | --- | --- | | Rule | One library card per member. A username is unique across members | | Mechanism | `[Unique]` on the event class (once per event source), or on a property (once per value) | | Alternatives | An expected-empty scope for a one-off claim (Chronicle used directly). A new aggregate, which expects an empty history | | Not | A read that asks “does it exist?” before the append. A decision read for value uniqueness: it guards one key’s history, not a value across keys | ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Events.Constraints; [EventType] [Unique(message: "This member already has a library card.")] public record LibraryCardIssued(); [Command] public record IssueLibraryCard(EventSourceId Member) { public LibraryCardIssued Handle() => new(); } [EventType] public record UserRegistered([property: Unique(message: "That user name is taken.")] string UserName); [Command] public record RegisterUser(string UserName) { public UserRegistered Handle() => new(UserName); } ``` There is no guard line: the kernel checks the constraint inside the append, for every append. ## What holds * A second `IssueLibraryCard` for the same member is rejected, whoever sends it and whatever the timing. Arc turns the violation into a validation error that carries your message. * Two registrations of the same user name cannot both succeed. `RegisterUser` has no event source id property, so each call creates a new user stream, which is right for a creation command. The constraint, not the stream, makes the name unique. ## What does not hold * A constraint is a fixed rule. If a user can change their name, release the old value with a removal event; see the [constraints](/chronicle/constraints/) documentation. * Constraints across namespaces are not checked: tenants in different namespaces can claim the same value. ## Related * [Pitfalls: a uniqueness check instead of a constraint](/arc/backend/csharp/chronicle/consistency/pitfalls/#a-uniqueness-check-instead-of-a-constraint) * [Chronicle constraints](/chronicle/constraints/) # Validators and read protection > Put a state-dependent rule in a CommandValidator with the read model as a constructor parameter. Under On the read is guarded; under Off it is a hint. Use this page when you write a rule such as “no copies left” or “the member has too many loans”. Under read protection `On`, the place for that rule is a `CommandValidator` that takes the read model as a constructor parameter. `Handle()` then says only what happens. ## Where each kind of rule goes | Rule | Put it in | Why | | --- | --- | --- | | The input is well formed (a name is not empty, a number is in range) | A validator without state, data annotations or a `ConceptValidator` | It needs no state, so there is nothing to race | | A value is unique | A constraint with `.WithMessage(...)` | The kernel enforces it for every writer. Arc returns your message | | The command may proceed only if stored state allows it | A `CommandValidator` with the read model as a constructor parameter, under `On` | The parameter is a protected read, so the approval is guarded | | The rule needs a value computed inside `Handle()` | `Handle()` returning `Result` | The read parameter of `Handle()` is protected too | | The user should see a hint before submitting | A query, or a validator over an `[Unprotected]` read | It is a hint. The rule is enforced elsewhere | ## A validator with a read model ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Projections.ModelBound; using Cratis.Concepts; using FluentValidation; public record Isbn(string Value) : EventSourceId(Value); public record MemberId(Guid Value) : ConceptAs(Value); [EventType] public record CopyAdded(); [EventType] public record BookBorrowed(MemberId Member); public record BookStock( EventSourceId Id, [Increment, Decrement] int Available); [Command] public record BorrowBook(Isbn Isbn, MemberId Member) { public BookBorrowed Handle() => new(Member); } public class BorrowBookValidator : CommandValidator { public BorrowBookValidator(BookStock? stock) => RuleFor(command => command.Isbn) .Must(_ => stock is { Available: > 0 }) .WithMessage("No copies of this book are available."); } ``` Under `On` this is guarded: 1. Arc starts the command’s unit of work and turns on read protection before the filters run. 2. Arc constructs the validator inside that unit. Its `BookStock` parameter is a protected read: Chronicle captures the position, folds the model, and enrols the guard before the validator sees the value. 3. The validator approves. `Handle()` returns the event. `Handle()` and the validator share the same instance and the same guard. The model is folded once per execution, whether the validator and `Handle()` take it as a plain parameter or as `DecisionRead`. 4. At commit, if a `CopyAdded` or `BookBorrowed` for that book arrived after the position captured in step 2, nothing is written. The caller gets a `400` with the reason `concurrencyViolation` and the detail `ReadChanged`. A validator that rejects writes nothing, so a rejection needs no guard and is never retried by Arc. The message reaches the client the same way as any validator message. The same rules apply to a nullable parameter: use `BookStock?` when absence is a business condition, and a non-nullable parameter when the command cannot run without the model. See [Nullable means you handle absence](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/#nullable-means-you-handle-absence). ## Caller severity cannot skip errors under declared On Declared read protection `On` supplies an `Error` blocking floor unless the command declares `[BlockOnValidationSeverity(...)]`. Validator errors and `Unknown` results block even when the caller sends `X-Allowed-Severity: 3`; warnings and information remain caller-filterable. The floor also applies to `Provide()` control results. A `Result` error returned by `Handle()` remains a rejection. The command’s read-protection attribute takes precedence over its own assembly’s attribute. Lowering the operational `ReadProtection:MaximumMode` does not remove this declared floor, although it can remove the read’s guard. An explicit blocking-severity attribute takes precedence. Under `Off` or `Measure`, callers can skip validator errors unless you declare `[BlockOnValidationSeverity(ValidationResultSeverity.Error)]`. See [Validation severity filtering](/arc/backend/csharp/commands/validation-severity-filtering/). ## A validator for one stream A plain constructor parameter whose model declares `[EventStream(stream)]` reads the command’s own stream, including its resolved stream-id template. Under `On`, validator and `Handle()` share one enrolled fold. Under `Off` or `Measure`, the fold stays stream-scoped but is unguarded. A missing template part returns `400` naming the property before any read; a mismatched route is a configuration error in every mode. Do not use `[LookUpBy]` to redirect a per-stream parameter. | Rule lives in… | Use | | --- | --- | | Folded state of one stream on the command’s own route | [Per-stream model and validator](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/#a-monthly-limit) | | The entity’s whole history, such as a balance | Whole-source model. Combining it with a per-stream model gives a whole-source guard | | “Has fact X happened in this short stream?”, without a model | Stream decision | | Rich behaviour across the whole entity | Aggregate, without reads of the same source | | Another month or another source | `IDecisionReads` stream overload in `Provide()` | | Uniqueness across sources | Constraint | A validator runs before `Provide()`. Do not also acquire a later stream decision for the same source: its boundary may not cover the validator’s earlier read. [ARCCHR0036](/arc/backend/csharp/chronicle/code-analysis/arcchr0036/) warns about that combination. ## Several keys in one validator Put `[LookUpBy(nameof(Command.Property))]` on each constructor parameter. The name is resolved on the command, not on the validator. Each read gets its own guard, and `Handle()` can return one `EventForEventSourceId` for each entity without implementing `ICanProvideEventSourceId`. See [Two entities](/arc/backend/csharp/chronicle/consistency/scenarios/two-entities/) and [the transfer example](/arc/backend/csharp/chronicle/consistency/reads/#choose-the-key-with-lookupby). ## What a validator may and may not do under On | In a validator | Under `On` | | --- | --- | | A read-model constructor parameter | Protected | | `[Unprotected("why")]` on the parameter or the model | Advisory. It guards nothing | | `IReadModels`, `IEventStore` or `IEventLog` used in a rule | Refused. Take the model as a parameter instead | | `IMongoCollection` of a Chronicle-backed model | Refused. Use `[Unique]` for uniqueness | | A service such as a price list | Unknown. It is outside the guard | | A validator registered by hand as scoped, singleton, a factory or an instance | Refused. Let convention discovery register it, which makes it transient | The last row is the one that surprises people. Under `On`, Arc builds a fresh validator for every command so that its read-model parameters belong to that command’s unit. A validator that the container supplies from another registration is refused with this error: ```text Discoverable validator 'BorrowBookValidator' cannot run while read protection is On because Arc did not construct it for this invocation. Let convention discovery register it as transient; do not use a scoped, singleton, factory or instance registration. ``` Arc checks the registration the container actually uses, which is the last one. Registering a validator again by hand after `AddCratisArc()` replaces the convention registration, so that validator is refused under `On` unless the new registration is again a plain transient binding of the validator to itself. Other constructor parameters, such as `ILogger`, are resolved from the container. ## The validate endpoint `{url}/validate` runs the filters and validators, and nothing else: no `Provide()`, no `Handle()`, no commit. Reads there are fresh, but nothing is guarded or written, because nothing commits. A successful validation does not promise that executing the command will succeed. The next execution reads again and guards again. A client that calls `/validate` and then executes therefore folds the model twice: once for the validation request and once for the execution. This is by design. The execution’s guard must start from a position that execution read itself, so a fold from an earlier request is never reused. Within one execution, validators and `Handle()` share a single fold. That makes a validator the right place for early feedback: the user sees “no copies left” as soon as the form is validated, and the same rule holds when the command executes. ## Under Off and Measure Under `Off` the validator runs as it always did, and its read-model parameter is a plain read. It is a hint: 1. Two members submit at the same time. Both validators read `Available: 1`. 2. Both validators pass. Both `Handle()` methods return `BookBorrowed`. 3. Both events are appended. A validator over a plain read is acceptable only when something else holds the rule: a constraint, an aggregate, a stream decision, an explicit scope, or a repair you accept. Mark the command’s read `[Unprotected("why")]` to say so. [ARCCHR0011](/arc/backend/csharp/chronicle/code-analysis/arcchr0011/) reports plain reads in event-producing commands under `Off`. `Measure` behaves like `Off` for the validator, and reports what `On` would do. ### Commands marked \[ProtectedDecision] By default, a `[ProtectedDecision]` command outside `On` runs a discoverable validator only if it has exactly one public, parameterless constructor. To take `DecisionRead` or the command-aware `IDecisionReads` instead, opt into [certified validator construction](/arc/backend/csharp/chronicle/consistency/certified-validators/). That opt-in requires an attested native Microsoft DI scope and an inspectable, fresh transient dependency graph. It supports an Arc-owned configuration callback, not arbitrary factories or prebuilt instances, and is available only on untrimmed JIT runtimes. Under `On`, a validator with plain read-model parameters remains allowed as described above, without the provider opt-in. If you are moving a command to `On`, move its state checks from `Handle()` to a validator, and drop `DecisionRead` for a plain parameter. ### What Arc does not check Arc certifies what it can see: the validator was constructed for this invocation. It does not certify that its rules read nothing but the parameters you declared. Keep these out of the validators of a protected command: * static members and service locators read by a rule; * property injection, or code that resolves the validator and sets a field a rule reads; * a decorator that returns the inner instance after changing its state; * `ApplyCondition` on an existing rule, a changed cascade mode, or rules added to a nested child validator. Custom `ICommandFilter` implementations are not checked at all. Do not read decision state in one. ## Where to go next * [Reads](/arc/backend/csharp/chronicle/consistency/reads/): what each read becomes under `On`. * [Scenarios](/arc/backend/csharp/chronicle/consistency/scenarios/): the same pattern for each rule shape. * [Pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/): what fails silently. * [Command validation](/arc/backend/csharp/commands/validation/): Arc’s validation model in general. * [Choosing how a rule holds in Chronicle](/chronicle/dynamic-consistency-boundary/choosing/): why a check before the append is not the guard. # Cratis package > Bootstrap Arc, the Chronicle client, and identity; configure the server and optional read-model database separately. The `Cratis` package bootstraps Arc, the Chronicle client, and identity with one dependency and two host calls. It does not start the Chronicle server or install/configure Arc’s MongoDB integration. Configure the server’s sink and the application’s query provider separately. ## What is the Cratis Package? The `Cratis` package is a convenience package that bundles the whole stack: * **Arc Application Framework** — CQRS commands and queries, validation, multi-tenancy, proxy generation * **Chronicle Event Sourcing** — the event store **client** (connecting to a separately running Chronicle server), aggregates, projections, reactors, and reducers * **Swagger/OpenAPI** — automatic API documentation It exists to get you to a running, end-to-end event-sourced application without wiring each component yourself. ## Installation Add the Cratis package to your ASP.NET Core project: ```bash dotnet add package Cratis ``` ## Basic Setup Configure Cratis in your `Program.cs` with one call on the builder and one on the app: Caution `AddCratis()` installs a [header-based identity adapter](/arc/backend/csharp/asp-net-core/microsoft-identity/), not token validation. It ignores the forwarded identity headers until you opt in with `builder.AddCratis(options => options.TrustForwardedIdentityHeaders = true)` or `Cratis:Arc:TrustForwardedIdentityHeaders`. Opt in only behind trusted ingress that authenticates users, strips/replaces incoming identity headers, and prevents direct backend access. Otherwise, [wire your own authentication](#running-arc-or-chronicle-on-their-own). ```csharp var builder = WebApplication.CreateBuilder(args); // Add Cratis (Arc + Chronicle) with default configuration builder.AddCratis(); var app = builder.Build(); // Wire up Cratis middleware and endpoints app.UseCratis(); app.Run(); ``` `AddCratis()` registers Arc’s command and query infrastructure, the Chronicle client (which connects to a separately running event store — see [what AddCratis sets up for you](#what-addcratis-sets-up-for-you)), Swagger, and validation/model binding. `UseCratis()` activates both halves — it calls `UseCratisArc()` and `UseCratisChronicle()` for you. ## What AddCratis sets up for you `AddCratis` is opinionated — it makes a few decisions so you don’t have to. Knowing them up front avoids surprises: * **It adds a Chronicle *client* — not the Chronicle engine.** `AddCratis` calls `AddCratisArc` and then `WithChronicle`, and `WithChronicle` registers the Chronicle **client**: a gRPC client that connects to a Chronicle **server running as its own separate process** — the `cratis/chronicle` container you deploy. Your application never runs the event store; it connects to one over gRPC using the connection string from configuration. When you read “Arc and Chronicle in one host,” it’s the *client* that shares your host — the engine runs elsewhere. * **Microsoft Identity Platform authentication is wired automatically** (`AddMicrosoftIdentityPlatformIdentityAuthentication`), but it trusts the forwarded identity headers only after you opt in with `TrustForwardedIdentityHeaders`. Until then every request is anonymous. If you don’t want identity baked in, wire Arc and Chronicle separately with `AddCratisArc` + `WithChronicle` instead of `AddCratis` — see [Running Arc or Chronicle on their own](#running-arc-or-chronicle-on-their-own). * **Chronicle is tenant-aware by default.** `WithChronicle` resolves the event store namespace per tenant (via `TenantNamespaceResolver`), so every event store is automatically scoped to the active tenant. See [Namespaces](/chronicle/namespaces/) for how the namespace becomes the tenancy boundary. There are always **two processes**: your application (Arc plus the Chronicle client) and the Chronicle server (the event store). `AddCratis` sets up the first and connects it to the second — it never starts the second for you. ``` cratis/chronicle — separate process / container Your application — one host gRPC (connection string) Arc — commands and queries Chronicle client Chronicle engine — the event store ``` Note The same `AddCratis` code connects to a local `cratis/chronicle` container in development and a shared Chronicle instance in production — only the connection string changes between environments. There is no in-process Chronicle to run, and you should not see event-store traffic served from inside your app. If you haven’t started a `cratis/chronicle` container, your app has nothing to connect to. ## How Arc and Chronicle fit together The two halves connect at a single seam: an Arc **command** appends a Chronicle **event**, a Chronicle **projection** turns events into a **read model**, and an Arc **query** serves that read model back to the client. ``` command appends projection query Client Arc command Chronicle event Read model ``` Arc and the Chronicle client share the application host and its configured integration services. The server’s materialized sink is a separate configuration boundary. For `IMongoCollection` queries, install `Cratis.Arc.MongoDB` and align database, collection names, key/serialization conventions, and tenant mapping with the sink. One host does not automatically share a MongoDB connection with the Chronicle server. ## Running Arc or Chronicle on their own `AddCratis` is the batteries-included front door, but the pieces underneath are independent — take just the part you need: * **Arc without an event store.** Call `AddCratisArc()` on its own and back your commands and queries with MongoDB or EF Core instead of Chronicle. You keep the full CQRS and proxy-generation experience with no event log. See [CQRS without event sourcing](/arc/arc-without-event-sourcing/). * **Arc + Chronicle without the baked-in identity.** Retain the **`Cratis` package reference** for this ASP.NET Core example. Call `AddCratisArc()` and add its ASP.NET Core `WithChronicle()` composition yourself. This is exactly what `AddCratis` does, minus `AddMicrosoftIdentityPlatformIdentityAuthentication()` — reach for it when you bring your own authentication. `Cratis.Arc.Chronicle` alone provides the generic-host integration, not all the ASP.NET Core extensions below. ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(configureBuilder: arc => Microsoft.AspNetCore.Builder.ArcBuilderExtensions.WithChronicle(arc)); var app = builder.Build(); app.UseCratisArc(); app.UseCratisChronicle(); // UseCratis() calls both — wire both halves yourself when you split them app.Run(); ``` The explicit static call selects the ASP.NET Core `WithChronicle()` composition. It avoids an ambiguous call when both `Cratis.Arc` and `Microsoft.AspNetCore.Builder` extensions are in scope; the generic-host overload is a different setup surface. Both compositions resolve `IUnitOfWorkManager` as a scoped service from the selected `IEventStore.UnitOfWorkManager`. Commands therefore use that event store’s configured lifecycle policy and the active tenant’s namespace, not a separately constructed manager. Both also keep an `IUnitOfWorkManager` you register explicitly, in any order relative to `WithChronicle`, and with whatever lifetime you chose. Arc excludes only the manager registration it adds by convention, and registrations you make before `WithChronicle` or inside its Chronicle builder callback are kept. An explicit manager takes precedence for single-service resolution; when you register it after `WithChronicle`, the event store’s manager is still registered too, so resolving all `IUnitOfWorkManager` services returns both. Configure `UnitOfWorkLifecyclePolicy` through the Chronicle options callback; do not register the enum as a service. The generic-host composition also supports a `WebApplicationBuilder` when using the `Cratis.Arc.Chronicle` package directly. Note Running Arc without Chronicle is a valid setup — but only if you don’t use Chronicle. If you call `AddCratisArc()` **without** `WithChronicle()` yet the project uses Chronicle (an aggregate root, reactor, reducer, projection, `[EventType]` event, or a command that injects `IEventLog`), the [ARCCHR0005](/arc/backend/csharp/chronicle/code-analysis/arcchr0005/) analyzer flags it at **compile time**. Should it slip through (for example, setup lives in a separate host project), resolution then fails at runtime with a message that points at the same fix: add `WithChronicle()`, or switch to `AddCratis()`. ## Advanced Configuration You can customize both Arc and Chronicle through the optional configuration callbacks: ```csharp builder.AddCratis( configureArcOptions: options => { // Configure Arc options (ArcOptions) }, configureArcBuilder: arcBuilder => { // Add additional Arc features arcBuilder.WithMongoDB(); }, configureChronicleOptions: options => { // Configure Chronicle options (ChronicleAspNetCoreOptions) options.EventStore = "my-store"; }, configureChronicleBuilder: chronicleBuilder => { // Configure Chronicle features chronicleBuilder.WithCamelCaseNamingPolicy(); }); ``` `options.EventStore` names the Chronicle event store the application connects to. The Chronicle options are bound from the `Cratis:Chronicle` section of `appsettings.json`, so the connection string and other settings come from configuration — see the [ChronicleOptions reference](/chronicle/configuration/chronicle-options/). ## Adding MongoDB Support By default, the Cratis package doesn’t include MongoDB support. To use MongoDB with your application, add the MongoDB package separately: ```bash dotnet add package Cratis.Arc.MongoDB ``` Then configure MongoDB using the `WithMongoDB` extension method: ```csharp builder.AddCratis( configureArcBuilder: arcBuilder => { arcBuilder.WithMongoDB(); }); ``` ### MongoDB Configuration Options You can customize MongoDB settings using the configuration callback: ```csharp builder.AddCratis( configureArcBuilder: arcBuilder => { arcBuilder.WithMongoDB( configureOptions: options => { options.Server = "mongodb://localhost:27017"; options.Database = "my-database"; }); }); ``` ### MongoDB Configuration from appsettings.json Alternatively, configure MongoDB settings in `appsettings.json`: ```json { "Cratis": { "MongoDB": { "Server": "mongodb://localhost:27017", "Database": "my-database" } } } ``` The `WithMongoDB` extension automatically reads these settings from `Cratis:MongoDB`. ### Custom Configuration Section Path If your MongoDB settings are in a different configuration section: ```csharp arcBuilder.WithMongoDB( mongoDBConfigSectionPath: "MyApp:Database:MongoDB"); ``` ## Adding Entity Framework Core Support To use Entity Framework Core with your application, add the Entity Framework Core package: ```bash dotnet add package Cratis.Arc.EntityFrameworkCore ``` Package installation alone does not register your contexts. Call `WithEntityFrameworkCore()` in `configureArcBuilder` and configure a nonempty connection string and eligible context types, or use explicit registration. Follow [Entity Framework Core registration and configuration](/arc/backend/csharp/entity-framework/getting-started/) for the required constructor, discovery options, and migration boundary. Pooled context registration does not automatically select a database per Arc tenant. Design and test tenant isolation separately; see the [provider limits](/arc/backend/csharp/entity-framework/getting-started/#scope-and-provider-limits). ## Program.cs composition This host-body fragment assumes the packages above, the relevant extension namespaces (`Cratis.Arc` and `Microsoft.AspNetCore.Builder`, plus Chronicle extensions used by your configuration), and configured authentication/server/sink connections. It composes registrations; it is not a standalone deployment checkpoint: ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratis( configureArcBuilder: arc => arc.WithMongoDB(), configureChronicleOptions: chronicle => chronicle.EventStore = "my-store", configureChronicleBuilder: chronicle => chronicle.WithCamelCaseNamingPolicy()); var app = builder.Build(); app.UseCratis(); app.Run(); ``` This is the same shape the `dotnet new cratis` full-stack template scaffolds. ## Next Steps Now that you have Cratis set up, you can: * Define [Commands](/arc/backend/csharp/commands/) to handle user actions * Create [Queries](/arc/backend/csharp/queries/) to retrieve data * Build [Aggregates](/arc/backend/csharp/chronicle/aggregates/) to model your domain * Configure [MongoDB](/arc/backend/csharp/mongodb/) for read models and projections * Set up [tenancy](/arc/backend/csharp/tenancy/) for your application For more advanced scenarios, explore the individual Arc and Chronicle components in the documentation. # Register composite protected-key accessors > Configure complete composite-key member coverage for strict protected-read validation without runtime property discovery. Use generated or typed accessors when protected read-model keys contain custom objects or records. Accessors describe the **exact runtime type**, not its subclasses. Scalar keys, concept unwrapping, and tuple elements do not need member descriptors; custom composites nested inside them do. ## Register a composite type The Arc model-graph generator emits a separate composite descriptor when your compilation references `Cratis.Arc.Chronicle` and the complete property set is known. It follows types reachable from model-bound commands and query arguments. It does not generate descriptors for partial types, external types or bases, experimental types, hidden or overridden members, or types generated code cannot safely name and read. For a statically declared uncovered composite key, [ARC0030](/arc/backend/csharp/code-analysis/arc0030/) explains the registration requirement without failing the build. Register every public readable non-indexed instance property before the host starts or commands run, including inherited properties. For example, this partial type requires manual coverage: ```csharp using Cratis.Arc.Chronicle.ReadModels; // In startup configuration, before building the host: ProtectedReadModelKeyAccessors.Register( new(nameof(WarehouseKey.Warehouse), key => key.Warehouse), new(nameof(WarehouseKey.Section), key => key.Section)); public partial record WarehouseKey(string Warehouse, string Section); ``` The getters are typed delegates, not expression trees compiled at runtime. Registration is trusted application code: omitting a property weakens your validation. Do not include static properties, indexers, or properties with non-public getters. Call `Register()` only for a type with no public readable non-indexed instance properties. An empty descriptor is distinct from an absent descriptor. Register every concrete derived runtime type separately, including inherited getters. A duplicate generated or manual registration throws `ProtectedReadModelKeyAccessorsAlreadyRegistered` instead of replacing coverage. ## Require coverage on JIT `ProtectedReadModelKeyOptions.Strict` defaults to `false`. Configure it through the host’s service collection: ```csharp using Cratis.Arc.Chronicle.ReadModels; using Microsoft.Extensions.DependencyInjection; builder.Services.Configure(options => options.Strict = true); ``` When dynamic code is unsupported, strict coverage is mandatory regardless of this option. An uncovered custom composite throws `ProtectedReadModelKeyAccessorMissing`, naming the CLR type and registration action, never the key value. This is a configuration error, not a missing-model validation failure: command-result mapping reports an exception and an unsuccessful execution, corresponding to HTTP 500. On a dynamic-code-capable JIT runtime with strict mode disabled, traversal prefers generated or manual descriptors, then uses the shared reflection compatibility helper. It reads public getters only. The fallback logs a warning once per exact type per process, naming the type and pointing to `ProtectedReadModelKeyAccessors.Register()` and `ProtectedReadModelKeyOptions.Strict`. It never logs key values. Generated and manually registered paths do not produce this warning. ## Startup and runtime checks Chronicle’s command-aware read registration installs a startup preflight. The generator supplies declared key-type metadata for source keys and explicit lookups, plus conventional keys on statically recognized read models. Preflight checks only discovered commands whose effective read protection is `On`. It checks registry coverage using types, without rediscovering properties; in compatibility mode it reports the fallback instead of rejecting startup. `DeclareKeyTypes` is generator infrastructure, not a manual command-key registration requirement. Raw command keys still come from the existing event-source selection sites. Neither registration nor preflight converts a key, acquires state, or changes the resolved target. Runtime traversal remains authoritative for nested composites, object/interface declarations, runtime-derived types, and commands without generated coverage metadata. It preserves reference-identity cycle detection, checks raw parts before conversion, and checks the already-resolved wire text even when a custom wire key has an empty descriptor. Missing-model rejection and read acquisition are separate from accessor coverage. This does not make protected reads wholly NativeAOT-safe Strict mode covers composite-part traversal only. Command-property discovery still uses existing reflection paths, which trimming can remove. Dynamic-code support does not detect trimming: a trimmed JIT application can still enter unsafe compatibility fallback unless you enable strict mode. Strict mode does not repair command-key discovery or establish whole-Arc NativeAOT support. # React to an event > Run a side effect or trigger a follow-up command automatically when a Chronicle event is appended — using a reactor. **Goal:** when an event-sourced slice records something — a book is added, an author is registered — you want something *else* to happen automatically: a notification goes out, another system is told, a follow-up command runs. That’s a reactor. This is part of Arc’s Chronicle integration. Direct database-backed Arc slices use commands, queries, and ordinary services; reactors become available when the write side records events in Chronicle. ## A reactor does, a projection shows A projection builds queryable state; a reactor *acts*. Where you’d reach for a projection to display data, reach for a reactor to cause an effect. `IReactor` is a marker interface — there’s nothing to override. Chronicle discovers supported handler signatures by the **type of their first parameter**. Register the integration and event types. For a returned-command reaction, use the supported `Task` signature shown in [command side effects](/arc/backend/csharp/chronicle/reactors/command-side-effects/). ## Do it 1. **For a side effect, call a collaborator.** Inject whatever does the work and handle the event: ```csharp public class NewArrivalsAnnouncer(INewArrivalsFeed feed) : IReactor { [OnceOnly] public async Task BookAdded(BookAddedToCatalog @event, EventContext context) => await feed.Announce($"New on the shelf: {@event.Title}"); } ``` 2. **To invoke another behavior, execute a command.** Prefer a returned command, or inject `ICommandPipeline` when you need to inspect the result. Chronicle also supports returned events directly; returning them preserves the side-effect pipeline without a manual default-log append: ```csharp public class CatalogIndexer(ICommandPipeline commands) : IReactor { [OnceOnly] public async Task BookAdded(BookAddedToCatalog @event, EventContext context) { BookId bookId = Guid.Parse(context.EventSourceId.Value); var result = await commands.Execute(new CreateSearchIndex(bookId, @event.Title)); if (!result.IsSuccess) { throw new SearchIndexingFailed(); } } } ``` These reactor fragments reuse `BookId`, `BookTitle`, `BookAddedToCatalog`, `CreateSearchIndex`, and the named `SearchIndexingFailed` exception from [command side effects](/arc/backend/csharp/chronicle/reactors/command-side-effects/). Place them in the same `Catalog.Books` namespace and supply `INewArrivalsFeed` for the first example. Import `System`, `System.Threading.Tasks`, `Cratis.Arc.Commands`, `Cratis.Chronicle.Events`, and `Cratis.Chronicle.Reactors` as needed. Convert the context’s framework identity to `BookId` at the boundary; the command and event retain their domain types. Throwing on an unsuccessful command fails the reactor so Chronicle’s failure/recovery policy applies. Logging and returning normally would acknowledge the event despite the failed indexing. A manual command has no inherited HTTP actor; configure authorization deliberately. [Returned-command execution](/arc/backend/csharp/chronicle/reactors/command-side-effects/#authorization-and-the-actor) explains system roles. To record events and invoke commands in the same reaction, [return a stable mixed collection](/arc/backend/csharp/chronicle/reactors/command-side-effects/#return-events-and-commands-together). Arc appends one event batch before executing commands in order. An append failure prevents every command; a later command failure does not undo the event batch or earlier commands. Use `EventForEventSourceId` for explicit destinations and keep both kinds of effects safe to repeat on retry. Direct appends to another event sequence or another store remain advanced options when a returned side effect cannot express the target. For the **default log**, return events unless an ordered workflow genuinely needs an append result, an observer-completion wait, or append options before it can proceed. In that case, [ARCCHR0003’s ordered manual append example](/arc/backend/csharp/chronicle/code-analysis/arcchr0003/#ordered-manual-appends) shows a narrowly scoped `#pragma warning disable ARCCHR0003` with a reason and a matching restore. The warning is guidance, not a runtime ban or a reason to silence the rule across a project. Manual appends do not appear in `ReactorScenario.Produced` or its `ShouldHaveProduced()` assertions. Test the append outcomes and ordering separately. Suppressing the warning provides neither replay protection nor a transaction across those appends: choose replay behavior, handle failures, and make retries safe. Design for idempotency `[OnceOnly]` skips these handlers during replay, not during live retries. A handler without replay exclusion can run on replay too. Make effects safe to repeat and derive decisions from the triggering event rather than assuming an asynchronously materialized model has caught up. ## See also * [Returning commands as side effects](/arc/backend/csharp/chronicle/reactors/command-side-effects/) — execute commands as side effects from a reactor. * [Add event sourcing to an Arc slice](/arc/backend/csharp/chronicle/add-event-sourcing/) — where reactors enter the Arc model. * [Return a result or an error](/arc/scenarios/return-a-result-or-error/) — what the command you execute can return. # Reactors > Reactors turn a recorded fact into an effect — a notification, an external call, or a follow-up command in another slice. A projection answers “what does this look like now?” A reactor answers “what should happen *because* of this?” After an author registration commits, a reactor can send a welcome email, update the search index, or start the next step. Keep follow-up that depends on committed facts separate from the original command’s decision. Choose when the work must happen Keep reactors or a durable workflow/outbox for reliable follow-up to committed facts. For immediate work chosen within a model-bound command, prefer returned [command operations](/arc/backend/csharp/commands/operations/). They do not provide durable delivery and are returned from the command, not directly from a reactor. [React to an event](/arc/backend/csharp/chronicle/react-to-an-event/) is the place to start: when to reach for a reactor, how method dispatch works by event type, and why idempotency matters. The topics here go deeper. ## Topics | Topic | Description | | --- | --- | | [Returning commands as side effects](/arc/backend/csharp/chronicle/reactors/command-side-effects/) | Let a reactor trigger follow-up commands by returning them — Arc executes them through the command pipeline automatically. | # Returning commands as side effects > Return discoverable command side effects from a Chronicle reactor, with explicit authorization, replay, and retry boundaries. When a book enters the catalog, its search index should follow. Return the indexing command from a reactor and let Arc execute it through the same validation and authorization pipeline as other commands. This keeps the reaction focused on **what should happen next**, rather than repeating command-execution plumbing. This behavior belongs to the optional `Cratis.Arc.Chronicle` integration. Reactor side effects are not command operations This page describes a reactor returning commands. Inside one of those commands, prefer [command operations](/arc/backend/csharp/commands/operations/) for immediate service side effects and optional compensation. Return the operation from the command’s `Handle()`, not directly from the reactor. Keep the reactor when work should follow a committed event; inline command operations do not add durable delivery, replay protection, or exactly-once execution. ## Return a command This complete type example assumes the integration is registered and your application supplies `ISearchIndex`. The event source id comes from the triggering event context rather than duplicating it in the event payload. ```csharp using System; using System.Threading.Tasks; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Reactors; using Cratis.Concepts; namespace Catalog.Books; public record BookId(Guid Value) : EventSourceId(Value) { public static readonly BookId NotSet = new(Guid.Empty); public static BookId New() => new(Guid.NewGuid()); public static implicit operator BookId(Guid value) => new(value); } public record BookTitle(string Value) : ConceptAs(Value) { public static readonly BookTitle NotSet = new(string.Empty); public static implicit operator BookTitle(string value) => new(value); } [Command] public record CreateSearchIndex(BookId BookId, BookTitle Title) { public Task Handle(ISearchIndex index) => index.Upsert(BookId, Title); } public interface ISearchIndex { Task Upsert(BookId bookId, BookTitle title); } /// Records a book added to the catalog with its title. [EventType] public record BookAddedToCatalog(BookTitle Title); public class CatalogIndexer : IReactor { [OnceOnly] public Task BookAdded(BookAddedToCatalog @event, EventContext context) { BookId bookId = Guid.Parse(context.EventSourceId.Value); return Task.FromResult(new CreateSearchIndex(bookId, @event.Title)); } } ``` `BookId` names the entity and gives its GUID event-source semantics; `BookTitle` names an ordinary domain value. These types prevent unrelated IDs or strings from being swapped in C# while retaining straightforward wire values. The conversion from the event context is explicit at the framework boundary. Reuse these concepts across the book feature instead of redeclaring them in each slice. Return `Task` so Chronicle recognizes the reactor handler before Arc processes its command result. This supported asynchronous signature also leaves room for real asynchronous work later; constructing the command itself does not need to be asynchronous. `[OnceOnly]` skips the method during replay. It does **not** mean exactly-once delivery or prevent retries after failure. Make `Upsert` and subsequent commands safe to repeat. ## Authorization and the actor Arc creates a dedicated service scope and executes returned commands with validation and authorization. This is not an HTTP request: the reactor does not inherit the original caller’s authenticated principal. For commands requiring an authenticated system actor, opt in on the reactor class with `Cratis.Arc.Chronicle.Reactors.ExecuteCommandsAsSystemAttribute`, supplying only the roles needed by its returned commands. This is an attribute fragment to apply to the reactor, not a blanket requirement: ```csharp [ExecuteCommandsAsSystem("catalog-writer")] ``` The attribute establishes system execution for **returned command side effects**. Arc keeps that actor within the command execution’s asynchronous lifetime, including service-scope creation and pipeline resolution. The caller’s identity remains unchanged on success, command rejection, exceptions, and setup failures. It does not automatically authorize manual `ICommandPipeline.Execute` calls inside the handler. Such calls need their own deliberate execution context. Do not confuse compliance subject or event causation with authorization credentials. ## Return several commands This handler fragment assumes `BookRemovedFromCatalog` and the three application command types exist; each command takes the same `BookId` concept. Import `System.Collections.Generic` for the collection: ```csharp [OnceOnly] public IEnumerable BookRemoved(BookRemovedFromCatalog @event, EventContext context) { BookId bookId = Guid.Parse(context.EventSourceId.Value); return [ new ArchiveBookMetadata(bookId), new RemoveFromSearchIndex(bookId), new NotifySubscribers(bookId) ]; } ``` `IEnumerable` is a supported synchronous discovery shape. Commands run sequentially in one service scope; execution stops on the first unsuccessful result. There is no transaction across the collection: each command has its own [transactional scope](/arc/backend/csharp/chronicle/commands/transactional-commands/). Earlier successes remain if a later one fails and may run again on retry. ## Return events and commands together When indexing also needs a recorded request, return a stable `IEnumerable`, `object[]`, or `List` containing both registered events and `[Command]` records. A `Task` returning one of these collections works too. This fragment replaces `CatalogIndexer` above and reuses its domain types. Import `System.Collections.Generic` and `Cratis.Chronicle.EventSequences`: ```csharp [EventType] public record BookIndexingRequested(BookTitle Title); public class CatalogIndexer : IReactor { [OnceOnly] public IEnumerable BookAdded(BookAddedToCatalog @event, EventContext context) { BookId bookId = Guid.Parse(context.EventSourceId.Value); return [ new EventForEventSourceId(context.EventSourceId, new BookIndexingRequested(@event.Title)), new CreateSearchIndex(bookId, @event.Title) ]; } } ``` Arc appends **all events as one batch first**, in their relative order, then executes the commands in their relative order. Interleaving commands with events in the returned collection does not interleave execution. Bare events use the reactor’s resolved event-source, stream, source-type, and subject metadata; `EventForEventSourceId` keeps its explicit destination, tags, named tags, and other append metadata. If append fails, no command runs. If a command fails, later commands stop, but the appended batch and earlier successful commands remain committed. There is no transaction or compensation across events and commands. A retry can append the events again and repeat commands that already succeeded, so make both effects safe to repeat. Return an array or list whose contents stay stable. Chronicle’s discovery and dispatch can enumerate the sequence more than once; do not produce effects while enumerating it. Each element must be a registered event, an envelope containing a registered event, or a command, never both an event and a command. Nulls, unknown values, and `CompleteStream` are not accepted in the mixed collection. `[ExecuteCommandsAsSystem]` applies only while executing the command partition, not while appending the event partition. ## Handle a command refusal Return an invocation when a business refusal should record a different outcome instead of failing the observer partition. This replacement for `CatalogIndexer` reuses `BookAddedToCatalog`, `BookId`, and `CreateSearchIndex` above. Import `Cratis.Arc.Chronicle.Reactors`: ```csharp [EventType] public record BookIndexingRefused(string Message); public class CatalogIndexer : IReactor { [OnceOnly] public CommandInvocation BookAdded(BookAddedToCatalog @event, EventContext context) { BookId bookId = Guid.Parse(context.EventSourceId.Value); return Invoke.Command(new CreateSearchIndex(bookId, @event.Title)) .OnRefused(Refused.ByConstraint("UniqueBook"), refusal => new BookIndexingRefused(refusal.Message)) .OnRefused(Refused.ByValidation, refusal => new BookIndexingRefused(refusal.Message)) .Acknowledge(Refused.ByAuthorization); } } ``` Synchronous `CommandInvocation` returns no longer raise `CHR0054` with Chronicle 19.63.0 or later. Use your command’s actual constraint name in place of `UniqueBook`. Register branch event types just like other returned events. The command still runs through validation and authorization; a branch cannot bypass either. The **first matching branch wins**. It returns a registered event, an `EventForEventSourceId`, or a stable nonempty sequence of those. Use `.Acknowledge(selector)` to acknowledge without producing events; a branch handler that returns `null` also produces no events. Acknowledgment records a handled refusal; it does not prove that the command’s work previously completed. Branches cannot return commands, operations, or stream completions. [ARCCHR0027](/arc/backend/csharp/chronicle/code-analysis/arcchr0027/) warns when a visible `OnRefused` callback returns a command, operation, completion, non-event object, empty batch, or a batch with an invalid member. It checks lambdas, anonymous methods, and method groups whose bodies are available. Opaque helper results still require runtime validation. The runtime validates the whole branch result before appending any of its events; an invalid result fails the delivery, not just the invalid member. A handled refusal appends its branch events and **stops all remaining commands or invocations** in this delivery. If the branch append fails, the reactor fails; another refusal branch cannot handle that append failure. Bare branch events use the same resolved event-source, stream, source-type, and subject metadata as ordinary returned events. Explicit envelopes retain their metadata. Return an invocation on its own, `Task`, or a stable sequence of invocations and plain commands. You can also mix them with events using the existing events-first rule: ordinary returned events append as one batch before any command runs, then branch events append when a refusal is handled. Previously appended events and successful commands remain committed on failure or acknowledgment. Refusal handling adds no transaction, redelivery deduplication, or exactly-once guarantee. The [Screenplay generator’s refusal extraction](/arc/backend/csharp/generating-a-screenplay/#command-refusal-branches) recovers pure `Invoke.Command(...).OnRefused(...)` and `.Acknowledge(...)` chains as ordered executable `on refused` branches at ESM v19. Opaque callbacks and caps below v19 keep the whole trigger file-backed with `SP0071`; runtime support does not imply every C# callback is extractable. ### Refusal selectors and details `CommandResult.GetRefusal()` in `Cratis.Arc.Commands` returns a `CommandRefusal`, or `null` for a non-refusal result. `CommandRefusal.Kind` is `CommandRefusalKind.Validation`, `Constraint`, or `Authorization`; `ConstraintNames` carries all distinct violated names in result order, with nullable `ConstraintName` retaining the first error constraint’s name; `Message` preserves authored text, including unresolved `$strings.` keys. | Selector | Matches | | --- | --- | | `Refused.ByValidation` | Error-severity authored rules, including property and concept rules and `require`. | | `Refused.ByConstraint("Name")` | An error constraint refusal with that exact, case-sensitive name among its violated names. | | `Refused.ByAnyConstraint` | Any error constraint refusal, including one without a name. | | `Refused.ByAuthorization` | An actual unauthorized result (`IsAuthorized` is false). | | `Refused.Any` | Validation and constraint refusals only, **never authorization**. | Selectors are inspectable sealed records: `Kinds` exposes the matched refusal kinds, and `ConstraintName` is the optional exact-name restriction. Equal kinds and names compare as equal selectors. Use `ByAnyConstraint` for an unrestricted constraint branch; `ByConstraint(name)` requires a name. For mixed business refusals, authorization wins, then constraints, then validation. Authorization uses `AuthorizationFailureReason` verbatim. For constraints, all error constraint messages join in result order with `\n`, and named selectors match any violated name. Validation joins all error rule messages in result order with `\n`. Joining does not trim or translate text. Messages are not stable identities; match constraint names rather than parsing prose. Exceptions and authorization configuration failures are **not refusals**. Error-severity framework-composed stream-completion errors, concurrency conflicts, validator failures, unavailable dependencies, malformed requests, and unknown validation reasons also prevent classification, even if the result contains a business refusal. Warning and information results never prevent classification. Unsupported policy outcomes are not authorization refusals. Success and warning- or information-only rule results do not classify as refusals. A warning that blocks a command under its declared severity policy therefore still fails the partition rather than being acknowledged by a validation branch. Concurrency remains a failure for Chronicle recovery to retry. An unhandled refusal and every non-refusal failure keep the existing failure behavior. Explicit authorization handling is deliberate: `Refused.Any` must not conceal a missing reactor actor. Custom `ICommandSideEffectExecutor` implementations must implement its context-and-event-store overload to support refusal branches. ## Failure and manual execution An unsuccessful plain returned command becomes a reactor side-effect failure. Chronicle’s observer failure/recovery policy then applies; do not assume failure means all previously completed side effects were undone. Prefer declarative refusal branches for business refusals. When you need other inspection of the `CommandResult`, inject `ICommandPipeline` instead. This method fragment belongs in an `IReactor` with a `commands` dependency and the same event/command types as above: ```csharp [OnceOnly] public async Task BookAdded(BookAddedToCatalog @event, EventContext context) { BookId bookId = Guid.Parse(context.EventSourceId.Value); var result = await commands.Execute(new CreateSearchIndex(bookId, @event.Title)); if (!result.IsSuccess) { throw new SearchIndexingFailed(); } } ``` Use a named exception for the failed operation rather than a generic exception: ```csharp public class SearchIndexingFailed() : Exception("Search indexing command failed."); ``` Here throwing deliberately tells the reactor to fail; logging and returning normally would acknowledge the event despite the failed command. [ARCCHR0006](/arc/backend/csharp/chronicle/code-analysis/arcchr0006/) warns when a manual execution handler omits `[OnceOnly]`. An asynchronous handler can perform manual work and then return supported side effects. Prefer one clear composition style, but mixing them is not a runtime prohibition. Chronicle also supports directly returned **events**; see [React to an event](/arc/backend/csharp/chronicle/react-to-an-event/). ## Complete the observed stream Return `CompleteStream` from `Cratis.Arc.Chronicle.Streams` (or `Task`) to close a stream without injecting the event log. Completion is scoped to the observed event’s event source id and event source type. Omitted stream parts use its stream type and id; redelivery of an already completed stream succeeds. This completion cannot be mixed with events or commands in a returned collection. Return a command when you need final events followed by completion. See [completing streams](/arc/backend/csharp/chronicle/commands/completing-streams/) for the scoped completion contract, Chronicle kernel 19.39 requirement, and non-atomic append/completion boundary. ## See also * [Commands](/arc/backend/csharp/chronicle/commands/) * [Chronicle reactors](/chronicle/reactors/) * [Transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/) * [Commands for occurrences](/arc/backend/csharp/chronicle/commands/occurrence-commands/) — retry-safe commands for timers, startup and shutdown, which are not reactor side effects # Read models > How Chronicle read models become command-scoped dependencies in Arc — what makes one injectable, how it is resolved by key, and what its lifetime is. A Chronicle read model is current state folded out of events. Arc makes that state available to a command as an ordinary constructor or method parameter, resolved for the key the command already carries — no query, no repository, no manual lookup. This section explains the mechanism. To *use* it, start with [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) for the recipe, or [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) for the full reference on each position. ## What makes a read model injectable A read model can be injected into command-scoped code because it is **resolvable by key** — the event source id Arc resolved from the command — through Chronicle’s read model store. That resolvability comes from a Chronicle backing artifact, so a read model is registered when it has one of: * a fluent [`IProjectionFor`](/chronicle/projections/) projection * a standalone model-bound projection (`[FromEvent]`, `[SetFrom]`, `[SetValue]`) * an [`IReducerFor`](/chronicle/reducers/) reducer * a [self-reducing read model](/chronicle/reducers/self-reducing-read-models/) discovered by Chronicle **19.58.0 or later** You declare the dependency identically in every case. The backing still matters for freshness, serialization, and compliance release; it is not always a materialized document. ### Children and nested objects are not standalone read models A type used only as a child or complex subobject of a model-bound projection is part of its parent’s document. Its mapping attributes do not give it an independent Chronicle lookup by key. Arc excludes those types from Chronicle’s model-bound command registrations, matching Chronicle’s projection discovery. Inject the parent and select the child from its state, or define a separate standalone read model for the command. A type that also has an explicit fluent projection or reducer remains eligible through that backing artifact. A self-referencing root is still eligible unless another candidate uses it as a child or subobject. This restriction concerns Chronicle ownership only. Arc leaves registrations from MongoDB, Entity Framework Core, or your application intact; those providers may independently own the same CLR type. Without such a registration, a required dependency fails resolution and a nullable dependency can receive null without a lookup. Making a child parameter nullable does not make it independently resolvable. ### `[ReadModel]` alone is not enough The `[ReadModel]` attribute does **not** make a type injectable into commands. `[ReadModel]` is an Arc concept used for queries, and it can be backed by stores other than Chronicle — Entity Framework Core, for example. Injection into command scope only makes sense for a type that is resolvable by key, and key resolution is owned by the backing provider. Chronicle registers the read models it can resolve; a read model backed by another provider is registered by that provider, not by Chronicle. The practical consequence: adding `[ReadModel]` to a record with no projection or reducer behind it will not make it appear in a validator. Add the backing artifact. ## Self-reducing read models When a projection cannot express a state transition, you can put the reduction on the model itself instead of creating a separate `IReducerFor`. Use Chronicle **19.58.0 or later** in your application. Arc remains compatible with older Chronicle clients, but those clients do not automatically discover this backing kind. This complete model declaration assumes an Arc host with [Chronicle configured](/arc/backend/csharp/chronicle/add-event-sourcing/); it is not a complete host: ```csharp using System.Threading.Tasks; using Cratis.Arc.Queries.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.ReadModels; namespace Banking.Ledgers; [ReadModel] public record LedgerBalance(EventSourceId Id, decimal Balance) { public static LedgerBalance Reduce(FundsAdded @event, LedgerBalance? current, EventContext context) => new(context.EventSourceId, (current?.Balance ?? 0) + @event.Amount); public static LedgerBalance? Reduce(LedgerClosed @event, LedgerBalance? current) => null; public static async Task ById(IReadModels readModels, EventSourceId id) => await readModels.GetInstanceById(id); } [EventType] public record FundsAdded(decimal Amount); [EventType] public record LedgerClosed; ``` `Reduce` receives the event and nullable current state; a null result removes the state. `ById` is an ordinary query. You do **not** need `RequireQueryAttribute`: Arc excludes any method accepting its own read-model type, regardless of name, parameter position, or nullability. Only `ById` becomes an endpoint and TypeScript query proxy. Apply [query authorization](/arc/backend/csharp/queries/model-bound/authorization/) before exposing private data. For command injection, Arc claims a concrete, non-generic type listed in Chronicle’s reducer artifacts only when it has at least one eligible **public static nongeneric** `Reduce(event, current[, EventContext])`. The first parameter may be any class other than `object`, or an interface; it does not need `[EventType]`. Chronicle’s event registry determines which registered concrete events match it, including explicitly registered events without an attribute. `current` is the exact nullable model type (nullable-oblivious declarations are also accepted). Return the model, nullable model, or `Task` of either. By-reference, optional, and `params` parameters are not eligible. A classic `IReducer`/`IReducerFor` implementation is not a self model, and explicitly nominating a plain reducer class does not make that class injectable as its own read model. Self models use the same command-key lookup and `Release(instance)` as other Chronicle backings, not MongoDB fallback. Their materialized/passive freshness and absence follow [Chronicle’s self-reducer contract](/chronicle/reducers/self-reducing-read-models/). Injecting them is still an advisory snapshot, **not concurrency protection**. Screenplay recognizes the same reducer shape, lists observed event types it can prove from `[EventType]` (including inherited markers), and reports the imperative fold as lost. When a parameter is an interface or unmarked class and its registered concrete events cannot be determined from source, it reports incomplete observed-event coverage rather than inventing a complete list. It does not translate reduction code into projection mappings. ## How an instance is resolved ``` Validator · Provide() · Handle()Chronicle read modelsCommand contextCommandValidator · Provide() · Handle()Chronicle read modelsCommand contextCommandalt[instance exists][never projected orremoved][Key] · EventSourceId · ICanProvideEventSourceIdGetInstanceById(type, eventSourceId)release with compliance subjectread model instancenull ``` Step by step: 1. **Identity strategy** — Arc inspects the command to determine its key, using one of the strategies in [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/). 2. **Command context lookup** — the resolved identity is read from the current `CommandContext`. 3. **Guard** — if no usable identity is available, resolution fails with [`UnableToResolveReadModelFromCommandContext`](/arc/backend/csharp/chronicle/read-models/failures/#unabletoresolvereadmodelfromcommandcontext). 4. **Store query** — Chronicle’s read model store is queried by the resolved identity. 5. **Subject release** — if the instance exists, Arc additionally calls `Release(instance)`, whether or not the command declares a compliance `Subject`. It does not pass the command subject. Chronicle resolves the release subject from the instance; materialized state may already have been released server-side. See [Subject](/arc/backend/csharp/chronicle/compliance/subject/). 6. **Result** — the instance is returned, or `null` when a materialized or reducer-backed instance does not exist. A never-created passive projection can instead return a default-valued instance; see [absence and passive projections](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/). Resolution happens exactly once per command. The same instance is handed to the validator, `Provide()`, and `Handle()`. ### The key does not prove existence A `[Key]` or event source id tells Arc *which* instance to resolve; it does not prove that instance exists. A projection may never have been created, may have been removed, or may be mid-rebuild. Whether that is a normal business condition or a fault is yours to declare — see [nullable versus required](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/#nullable-means-you-handle-absence). ## Lifetime and mutability Read models are registered as **command-scoped** services: * The state is fetched once per command and shared across that command’s validator, `Provide()`, and `Handle()`. * The instance is tied to the identity resolved from the command context. * It is disposed when the command completes. And they are **read-only snapshots**: * **Immutable in practice** — changes made to an injected instance are not persisted anywhere. * **Backing-dependent freshness** — asynchronously materialized models reflect events processed so far, not necessarily every append. Passive models are built on demand. * **Fetched on first resolution** — the command-scoped snapshot is not refreshed after a later append or returned identity change. To *change* state, return events from `Handle()` or use an [aggregate root](/arc/backend/csharp/chronicle/aggregates/). A read model is an input to a decision, never the place a decision is recorded. ## Materialized and passive paths Passive reducer models are folded in-process. Passive projection models register with no materialized sink and are resolved on demand by Chronicle. Ordinary materialized reads cross the service/JSON boundary; a passive reducer does not use that same document-deserialization path. Arc calls the non-generic `GetInstanceById(Type, key)` overload. For passive reducers, do not assume it performs the generic overload’s post-reduction release. Arc attempts its own `Release(instance)` for every existing command-side instance, and Chronicle decides from the instance whether there is anything to release. Test sensitive state on the path you actually use. On-demand state is still a snapshot, not a lock. Neither passive nor materialized read-model injection by itself binds the later append to the revision used by a decision. Use append-time constraints or an explicitly captured concurrency expectation where needed. ## Read models or aggregate roots Both give a command access to current state, and they answer different questions. | Reach for | When | | --- | --- | | **Read model** | You need projected, possibly denormalized state to validate against or compute from, and eventual consistency is acceptable | | **Aggregate root** | You need to emit events, enforce an invariant inside a consistency boundary, or work from source-of-truth stream state | | **Both** | Validate against projected state, then make the change through the aggregate | If correctness depends on source-of-truth state under concurrency, prefer aggregate or event-stream state over a read model — or enforce it with a Chronicle [constraint](/chronicle/constraints/), which is checked at append time. ## Topics | Topic | Description | | --- | --- | | [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) | Injecting into a validator, `Provide()`, and `Handle()`, and what nullability means. | | [Read models from other providers](/arc/backend/csharp/chronicle/read-models/other-providers/) | Injecting one backed by Entity Framework Core or MongoDB, and declaring the key without Chronicle. | | [When resolution fails](/arc/backend/csharp/chronicle/read-models/failures/) | Every failure mode, what it means, and how to fix it. | # When read model resolution fails > Every failure mode for a command-scoped Chronicle read model — what the client sees, what it means, and how to fix it. Read-model resolution distinguishes invalid input from missing infrastructure. An unusable key or an absent required read model is a **validation failure (HTTP 400)**. Missing service registrations and execution outside the command pipeline are configuration problems, not evidence that an entity does not exist. | Failure | Cause | Client sees | | --- | --- | --- | | [`UnableToResolveReadModelFromCommandContext`](#unabletoresolvereadmodelfromcommandcontext) | The command carries no usable key | HTTP 400 — “The command is missing the identifier required to load its current state.” | | [`ReadModelDoesNotExistForCommand`](#readmodeldoesnotexistforcommand) | Valid key, but a required (non-nullable) read model does not exist | HTTP 400 — “The command targets an entity that does not exist.” | | [`ReadModelValidatorRequiresCommandPipeline`](#readmodelvalidatorrequirescommandpipeline) | The validator ran through MVC model binding, before a command context existed | Request fails | | [`CannotResolveCommandDependency` / `CannotResolveValidatorDependency`](#cannotresolvecommanddependency-and-cannotresolvevalidatordependency) | A required non-nullable dependency could not be resolved | Depends on the dependency | The two validation failures above use generic client-facing messages. Do not generalize that to every exception: Arc’s exception-detail exposure is configurable, and enabling it can expose type names and other details. Keep production exception redaction and authorization configured independently. ## UnableToResolveReadModelFromCommandContext The resolved key is unusable, so there is nothing to resolve a read model by. With Chronicle, this means `EventSourceId.Unspecified` — for example, a declared key whose value is null, or an `ICanProvideEventSourceId` implementation that cannot compose an identity. Without Chronicle, it also includes commands on which no key is declared. ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Keys; [Command] public record CheckCustomer([Key] string? CustomerId) { // A null CustomerId leaves the declared key unspecified. // Making the read-model dependency nullable does not permit an unusable key. public bool Handle(Customer? customer) => customer is not null; } ``` ### Keyless creation commands are different When a Chronicle command declares **no** event-source key and does not implement `ICanProvideEventSourceId`, Chronicle generates a new identity so the command can create an entity. That is a usable key, not `EventSourceId.Unspecified`. If no read model exists for the generated identity, a nullable dependency receives `null`; a required dependency produces `ReadModelDoesNotExistForCommand`. A command targeting an existing entity must declare that entity’s identity instead of relying on this creation fallback. For an unusable declared key, the failure is not “the entity does not exist” — the lookup cannot be performed, for a nullable and a non-nullable parameter alike. Making the parameter nullable does **not** suppress `UnableToResolveReadModelFromCommandContext`. **Fix:** give the command a key. What counts as one depends on whether the application has Chronicle: | Setup | Declare the key by | | --- | --- | | With Chronicle | marking a property or matching positional parameter with `Cratis.Chronicle.Keys.KeyAttribute`, using an `EventSourceId` or `EventSourceId`-derived property, or implementing `ICanProvideEventSourceId` — see [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/) | | Without Chronicle | marking a property with `System.ComponentModel.DataAnnotations.KeyAttribute`, or implementing `ICanProvideKeyForCommand` — see [Declaring the key without Chronicle](/arc/backend/csharp/chronicle/read-models/other-providers/#declaring-the-key-without-chronicle) | The data annotations attribute does not declare a Chronicle key. Unless another identity source is present, using it triggers the generated-ID fallback, not the missing-key error. [ARCCHR0008](/arc/backend/csharp/chronicle/code-analysis/arcchr0008/) reports that attribute mismatch at build time. ## ReadModelDoesNotExistForCommand The command carried a valid key, but no read model exists for it — and the dependency was declared **non-nullable**, so Arc cannot inject anything. This is the runtime counterpart of the choice [ARC0006](/arc/backend/csharp/code-analysis/arc0006/) asks you to make. Arc treats it as a rejected command rather than a server fault, because “you asked me to act on an entity that isn’t there” is invalid input. **Fix — pick the one that matches your intent:** * **Absence is a business condition.** Make the parameter nullable and write the rule around `null`: ```csharp public class RemoveContactValidator : CommandValidator { public RemoveContactValidator(Customer? customer) => RuleFor(_ => customer) .NotNull() .WithMessage("Customer is not registered"); } ``` You get a specific message instead of the generic one, which is almost always the better experience. * **The projection really is required.** Leave it non-nullable — the HTTP 400 is the intended behavior, and nothing needs to change. For an asynchronously materialized model, the projection may not have caught up yet: a command issued immediately after the creating event can arrive first. Passive models instead build state on demand; see [backing differences](/arc/backend/csharp/chronicle/read-models/#materialized-and-passive-paths). For an invariant that must hold regardless, use a Chronicle [constraint](/chronicle/constraints/) instead of projected state. ## ReadModelValidatorRequiresCommandPipeline A `CommandValidator` that depends on a read model was constructed through the **MVC controller** model-validation path. MVC runs validation during model binding — before the command context, and therefore before the event source id, exists. The validator cannot be constructed, so the request fails. This affects MVC controllers only. Minimal-API command endpoints (the Arc default) and direct `ICommandPipeline` execution both establish the command context first and work correctly. **Fix:** expose the command through a minimal-API command endpoint, or move the read-model based check out of the validator and into the command’s `Handle()` method. ## CannotResolveCommandDependency and CannotResolveValidatorDependency The general case: Arc needed to invoke `Provide()`, `Handle()`, or construct a discoverable validator, and a required non-nullable parameter could not be resolved or resolved to `null`. For a registered read model with a valid event source id, Arc classifies the failure as `ReadModelDoesNotExistForCommand` instead — so if you are seeing these types for a read model parameter, the cause is usually one of: * **No provider registered the read model for command-scope injection.** Chronicle requires a backing projection or reducer; `[ReadModel]` alone does not make Chronicle own it. MongoDB and Entity Framework Core supply their own registrations. See [what makes a read model injectable](/arc/backend/csharp/chronicle/read-models/#what-makes-a-read-model-injectable) and [other providers](/arc/backend/csharp/chronicle/read-models/other-providers/). * **The parameter is not a read model at all** — an ordinary service that is not registered. Arc deliberately leaves these as server errors rather than masking them as validation failures, so genuine misconfiguration is not hidden behind an HTTP 400. A nullable dependency can also receive `null` when its type is not registered at all: nullable DI alone does not prove that a read-model lookup took place. Verify provider registration before interpreting that null as an absent entity. ## See also * [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) — declaring the dependency and choosing nullability. * [ARC0006](/arc/backend/csharp/code-analysis/arc0006/) — the analyzer that surfaces the nullability choice at build time. * [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/) — how the key is found in the first place. # Read models in commands > Take a Chronicle read model as a dependency in a CommandValidator, a Provide method, or a Handle method — and declare what a missing instance means. A command can take the read model Arc resolved for its key in three places: the constructor of a `CommandValidator`, a `Provide()` method, and a `Handle()` method. All three resolve from the same command scope, so all three see the same instance. A plain read guards nothing under Off Under read protection `Off`, the default, an injected read model is a **plain read**: it returns a snapshot, captures no boundary and guards nothing, in a validator, in `Provide()` and in `Handle()` alike. Under `On`, the same parameter is read fresh and guarded. The next major version of Arc makes `On` the default; see [The default changes in the next major version](/arc/backend/csharp/chronicle/consistency/#the-default-changes-in-the-next-major-version). Use a constraint for uniqueness. See [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) and [Getting started with read protection](/arc/backend/csharp/chronicle/consistency/getting-started/). Which one to use is a question about *what the state is for*. These are focused dependency fragments using application read models, events, and commands; declarations without `Handle()` show only the validator’s input shape, not complete runnable commands. | Position | Use it when | The state is… | | --- | --- | --- | | `CommandValidator` | The command should be rejected with a message | a gate | | `Handle()` | The event you produce is computed from the state | an input | | `Provide()` | The state has to be combined with fetched data before the decision | an input to acquisition | ## In a validator Validators run before the handler, so a validator can reject early with a message. The read model is an ordinary constructor dependency. Under `Off` the rejection is advisory: two concurrent commands can both pass it. Under `On` the parameter is guarded, so a command that passed on stale state is not committed. See [validators and read protection](/arc/backend/csharp/chronicle/consistency/validators/): ```csharp [Command] public record SettleLedger(EventSourceId LedgerId) { public LedgerSettled Handle(LedgerBalance balance) => new(balance.Balance); } public class SettleLedgerValidator : CommandValidator { public SettleLedgerValidator(LedgerBalance balance) => RuleFor(command => command.LedgerId) .Must(_ => balance.Balance > 0) .WithMessage("Ledger has no funds to settle."); } ``` Validators are discovered by convention — there is nothing to register. Their messages reach the client through `CommandResult` like any other validation error. Validators need the Arc command pipeline Read-model injection into a validator works for commands that run through the Arc command pipeline — minimal-API command endpoints (the default) and `ICommandPipeline` directly. It does **not** work through MVC controllers, because MVC model validation runs during model binding, before the command context exists and therefore before there is an event source id to resolve by. The request fails with [`ReadModelValidatorRequiresCommandPipeline`](/arc/backend/csharp/chronicle/read-models/failures/#readmodelvalidatorrequirescommandpipeline). Expose the command through a minimal-API endpoint, or move the check into `Handle()`. ## In `Handle()` When the state is an input to the event rather than a gate on it, take it in the handler: ```csharp [Command] public record UseReducerReadModelInHandle(EventSourceId AccountId) { public BalanceRecorded Handle(ReducerAccountSummary summary) => new(summary.Balance); } ``` The injection signature is the same for classic reducers, [self-reducing models](/arc/backend/csharp/chronicle/read-models/#self-reducing-read-models) (Chronicle 19.58.0+), fluent projections, and model-bound projections, including passive models. A self model needs no separate reducer registration in Arc: Chronicle’s discovered reducer set supplies it. Existing instances are read through `IReadModels` and released before injection; absent self-model state resolves to null. Under `Off`, none of these snapshots protects a later append against concurrent changes. Under `On`, a model that read protection can guard is read fresh and guarded, and a reducer that it cannot guard is refused. Freshness and release paths differ; see [materialized and passive paths](/arc/backend/csharp/chronicle/read-models/#materialized-and-passive-paths). ## In `Provide()` `Provide()` acquires the data `Handle()` needs, and its return value is passed to `Handle()` as an argument. A read model can be one of `Provide`’s own inputs: ```csharp [Command] public record ProvideReadModelDependencyCommand(EventSourceId AccountId) { public ProvidedAccountBalance Provide(AccountBalanceReadModel readModel) => new(readModel.Balance); public ReadModelDependencyProvided Handle(ProvidedAccountBalance balance) => new(balance.Value); } ``` Use this shape when the projected state has to be combined with something fetched — a rate, a policy, an external lookup — before `Handle()` can decide. For plain validation, prefer a validator; `Provide()` exists to keep IO out of the decision, not to host rules. See [Provide data to a command handler](/arc/scenarios/provide-data-to-a-command/). ## Nullable means you handle absence The key on a command identifies *which* read model instance to resolve. It does not prove that instance exists. Nullability is how you declare what absence means, and Arc behaves differently for each choice. ### Nullable — absence is a business condition Declare the parameter nullable when “does not exist” is a state your rule is written around. Arc injects `null` and your code decides: ```csharp [Command] public record RegisterCustomer([Key] Guid CustomerId, string Name); public class RegisterCustomerValidator : CommandValidator { public RegisterCustomerValidator(Customer? customer) => RuleFor(_ => customer) .Null() .WithMessage("Customer is already registered"); } ``` The mirror image — reject when the entity is *missing* — is the same shape with the rule inverted, and `When` guards the rules that dereference it: ```csharp public class AssignPersonToRoleValidator : CommandValidator { public AssignPersonToRoleValidator(RoleReadModel? role) { RuleFor(_ => role) .NotNull() .WithMessage("Role does not exist"); When(_ => role is not null, () => { RuleFor(command => command.PersonId) .Must(personId => !role!.AssignedPersonIds.Contains(personId)) .WithMessage("Person is already assigned to this role"); RuleFor(command => command) .Must(_ => role!.Status == RoleStatus.Active) .WithMessage("Cannot assign people to inactive roles"); }); } } ``` ### Non-nullable — the projection is required Keep the parameter non-nullable when the command genuinely requires the projection and cannot evaluate its rules without it. Arc then fails the command with [`ReadModelDoesNotExistForCommand`](/arc/backend/csharp/chronicle/read-models/failures/#readmodeldoesnotexistforcommand) before your code runs, and you write rules against the state directly: ```csharp [Command] public record SubmitOrder([Key] Guid OrderId); public class SubmitOrderValidator : CommandValidator { public SubmitOrderValidator(OrderReadModel order) { RuleFor(_ => order.Status) .Equal(OrderStatus.ReadyForSubmission) .WithMessage("Only orders that are ready for submission can be submitted"); RuleFor(_ => order.Lines) .NotEmpty() .WithMessage("Order must have at least one line"); } } ``` A missing `OrderReadModel` here is a dependency-unavailable validation failure, not a domain-rule rejection: the validator declared the projection required, so its rules could not be evaluated. ### The analyzer makes the choice explicit [ARC0006](/arc/backend/csharp/code-analysis/arc0006/) reports a warning on every non-nullable command-scoped read model parameter, in a validator, `Provide()`, or `Handle()`. It is not saying non-nullable is wrong — it is making sure the required-state choice was made deliberately rather than by default. The same nullability rules apply in all three positions: ```csharp [Command] public record UseNullableReducerReadModelInHandle(EventSourceId AccountId) { public ReadModelAbsenceRecorded Handle(ReducerAccountSummary? summary) => new(summary is null); } ``` ## Combining with an aggregate root A command can take both — projected state as context, and the aggregate as the thing that changes: ```csharp [Command] public record AddItemToCart([Key] Guid CartId, Guid ProductId, int Quantity) { public async Task Handle( ShoppingCart cart, ShoppingCartSummary? summary, ILogger logger) { logger.LogInformation("Cart had {Count} items", summary?.TotalItems ?? 0); await cart.AddItem(ProductId, Quantity); return await cart.Commit(); } } ``` This fragment assumes an asynchronous aggregate mutation and `Cratis.Arc.Chronicle.Aggregates.AggregateRootCommitResult`. Do not also return the event the aggregate applies. Explicit commit propagates aggregate failures but finalizes the shared unit of work; see [commit boundaries](/arc/backend/csharp/chronicle/aggregates/defining-an-aggregate-root/#reporting-failure-and-committing). Read models never emit events. Under `Off`, materialized models can lag and passive models are snapshots rather than locks. For concurrent invariants use a Chronicle [constraint](/chronicle/constraints/) for uniqueness, or read protection `On` for state. An aggregate guards only its own event source and the event types it handles. Under `On`, an aggregate and a plain read of the **same** event source in one command are refused; a read of a different source is allowed. See [choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/). ## Decision reads for event-dependent commands Under read protection `On`, a plain `T` parameter in `Handle()`, `Provide()` or a validator constructor is a guarded read. Nothing else is needed on the command. [Getting started](/arc/backend/csharp/chronicle/consistency/getting-started/) shows the whole path, and [Reads](/arc/backend/csharp/chronicle/consistency/reads/) lists what each read becomes. | You write | Under `Off` or `Measure` | Under `On` | | --- | --- | --- | | `Handle(BookStock? stock)`, or a validator constructor parameter | Plain read | Guarded read | | `[Unprotected("why")]` on the parameter or the model | Plain read | Advisory read: stored, unguarded | | `[LookUpBy(nameof(Property))]` on a parameter | Selects the key | Selects the key | | `DecisionRead` with `[ProtectedDecision]` | Guarded read for that command | Guarded read. The attribute is redundant | | `IDecisionReads.Get(key)` in `Provide()` | Guarded for `[ProtectedDecision]` commands | Guarded read | | `IReadModels`, `IEventStore` or `IEventLog` reads | Allowed | Refused unless the method is `[Unprotected("why")]` | A conflict returns `concurrencyViolation` with the detail `ReadChanged`, and the command is not retried. See [Refusals](/arc/backend/csharp/chronicle/consistency/refusals/). `Validate` is advisory: it reads fresh state and does not append or guard, so a successful validation response does **not** promise that `Execute` will commit. ### Commands marked \[ProtectedDecision] A command that is not under `On` can opt in on its own. **Mark it with `[ProtectedDecision]`** (`Cratis.Arc.Chronicle.ReadModels`) before injecting `DecisionRead` into `Provide()` or `Handle()`; use `.Instance` and `.Exists`. Inject the command-aware `IDecisionReads` and call `Get(otherKey)` to guard a different event source. The profile is fixed when the Arc pipeline enters each command, including nested commands. An unmarked command under `Off` keeps its plain reads, and `IDecisionReads.GetDetached` returns a detached snapshot as Chronicle’s own reader does. Inside a `[ProtectedDecision]` command `GetDetached` is refused, because a detached snapshot is not enrolled in the decision. A `DecisionRead` returned from `Provide()` must have been issued in that invocation. By default, a `[ProtectedDecision]` command outside `On` runs a discoverable validator only if it has one public, parameterless constructor. [Certified validator construction](/arc/backend/csharp/chronicle/consistency/certified-validators/) opts dependency-taking validators into fresh construction in a native Microsoft DI scope: use `DecisionRead` or the command-aware `IDecisionReads`, not a plain read-model parameter. This capability requires an untrimmed JIT runtime. Under `On`, validators with plain read-model parameters remain allowed without that opt-in. See [Validators and read protection](/arc/backend/csharp/chronicle/consistency/validators/#commands-marked-protecteddecision). ```csharp [Command] [ProtectedDecision] public record RenameLedger(EventSourceId LedgerId, string Name) { public LedgerRenamed Handle(DecisionRead balance) => new(Name); } public class RenameLedgerValidator : CommandValidator { public RenameLedgerValidator() => RuleFor(command => command.Name).NotEmpty().WithMessage("A ledger needs a name."); } ``` ### What is guarded A guarded read requires a model that Chronicle admits for direct, event-source-keyed event-log reads with a supported key conversion and a known, nonempty event-type set. Flat projections, unfiltered reducers and self-reducing models without constructor dependencies other than `ILogger` are admitted. Reducers with a filter or other dependencies, joins, nested or child sources, `FromEvery`, by-property keys, stream-keyed projections read as a plain parameter, open-ended event types and Pinned generation delivery are refused. The model needs an `Id` property of a string-like or `Guid` type, which is matched to the command’s key or to a `[LookUpBy]` property. Revisions, redactions, projection definition changes, other event sequences, external state and writes that bypass the kernel’s append path are not guarded. A guard detects every matching event appended through the kernel, whoever wrote it. An unguarded writer’s own decision is not protected. Arc claims ownership of the command’s Chronicle unit of work and commits or rolls it back as owner. A direct `Commit()` or `Rollback()` of that unit from command code is refused with `ProtectedUnitOfWorkRequiresOwner`. Immediate `IEventLog.Append*` calls inside a handler are not part of the guarded commit: under `On` they are refused; return events instead. ### Read models scoped to one event stream Declare `[EventStream(stream)]` from `Cratis.Chronicle.ReadModels` on a source-keyed model when it always represents one stream. Take it as a plain parameter in `Handle()`, `Provide()` or a `CommandValidator` constructor. Arc derives the source id, source type, stream type and stream id from the command’s route, including `[EventStreamId("{Month}")]` templates. The [monthly-limit example](/arc/backend/csharp/chronicle/consistency/scenarios/per-stream/#a-monthly-limit) includes the declaration, validator and a `ForMonth` query. | Mode | Per-stream parameter | | --- | --- | | `On` | Fresh stream fold, enrolled before the value is returned; validator and handler share one fold | | `Off` or `Measure` | Plain stream fold, no guard | | `On` with `[Unprotected("why")]` on the parameter or model | Advisory stream fold, no guard | A different source or stream, `All`, or a missing/default stream id is refused in every mode with `ReadModelStreamNotOnCommandRoute`. A missing template part returns `400` naming the command property before reading. `[LookUpBy]` is refused on per-stream parameters: the source belongs to the command’s route, not the lookup convention. A `DecisionRead` of a declared per-stream model uses that same route; other models remain key-based. The model is implicitly passive. Queries use `IReadModels.GetInstanceById(EventSourceId, EventStreamId)`, which supplies no guard; key-only reads and watchers are refused. Combining a stream model with a whole-source read on the same source chooses a whole-source guard. All writers must use the definition route: events without its source type are neither folded nor guarded. ### Read a projection within one stream For another stream or source, or a projection without a per-stream declaration, inject the command-aware `IDecisionReads` and pass the source id, stream type and stream id explicitly. This command fragment assumes an admitted event-source-keyed `PeriodState` projection with an integer `Value`, and a registered `PeriodValueDecided(int Value)` event: ```csharp using Cratis.Arc.Chronicle.ReadModels; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.ReadModels; [Command] [EventSourceType("reports")] [EventStreamType("approval")] [EventStreamId("{Period}")] public record DecideWithinPeriod(EventSourceId Id, string Period) { public Task> Provide(IDecisionReads reads) => reads.Get(Id, "approval", Period, "reports"); public PeriodValueDecided Handle(DecisionRead state) => new(state.Instance?.Value ?? 0); } ``` Under `On` the command needs no attribute. Outside `On`, add `[ProtectedDecision]`. The fold and its append guard use those same dimensions. `Provide()` returns the issued token to `Handle()`; Arc enrolls it in the command transaction and caches identical reads only within that invocation. A whole-source read or another stream’s read does not share that cache entry. The optional event source type narrows both the fold and guard. Keep it aligned with the returned event route. Use `AdmitForStream()` to check the projection shape without reading. Chronicle also admits consistently stream-id-keyed projections through this overload; reading them through a key-only `Get` refuses with `StreamKeyRequiresStreamScope`. An injected `DecisionRead` is narrowed automatically only when `T` declares `[EventStream(stream)]`; otherwise it stays key-based. Use the explicit stream overload for another stream or source. The stream type must be specific, not `EventStreamType.All`. See Chronicle’s [stream-scoped decision-read contract](/chronicle/read-models/decision-reads/#scoped-to-one-event-stream) for default-id behavior, key requirements and merged guard widening. The kernel must acknowledge stream scoping; unsupported scoping fails rather than silently folding the whole source. For the decision matrix, costs, pitfalls and scenarios that put these pieces together, see [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/), its [scenarios](/arc/backend/csharp/chronicle/consistency/scenarios/) and its [pitfalls](/arc/backend/csharp/chronicle/consistency/pitfalls/). ## Read models from other providers Injection is not Chronicle-only. A read model backed by Entity Framework Core or MongoDB is injected into a command exactly the same way, and everything on this page — the three positions, and what nullability means — applies unchanged. What differs is where the read model is loaded from and what key loads it, including how a command declares its key when there is no Chronicle to resolve one. See [Read models from other providers](/arc/backend/csharp/chronicle/read-models/other-providers/). ## Testing For legacy plain read models, seed state with the `Given` builder — either events or a pinned instance. For guarded reads, explicitly select the [single-log decision scenario](/arc/backend/csharp/testing/chronicle/#protected-decision-scenarios) and seed actual events; pinned state cannot supply a valid guard. See [Test the race](/arc/backend/csharp/chronicle/consistency/scenarios/testing/). ## See also * [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) — the short recipe. * [When resolution fails](/arc/backend/csharp/chronicle/read-models/failures/) — every error and what it means. * [Command validation](/arc/backend/csharp/commands/validation/) — the rest of Arc’s validation model. # Read models from other providers > Inject a read model backed by Entity Framework Core or MongoDB into a command, and declare the key it is loaded by when there is no Chronicle to resolve one. Injection is not Chronicle-only. Any provider that owns a read model’s storage can make its `[ReadModel]` types injectable into a command, resolved by the same key, so a validator, `Provide()`, or `Handle()` takes the read model exactly as it would a Chronicle-backed one. This compatibility reference covers *where the read model comes from* and *what key loads it*. Provider-neutral injection does not require Chronicle. The EF and MongoDB examples below assume standalone Arc and use its data annotations command key; if you register Chronicle, use its identity rules instead. The examples are dependency/key fragments: returning `CustomerRenamed` alone does not persist a rename without a handler or integration that performs that effect; a complete standalone rename must write through its provider. For where to put the parameter and what a nullable one means, see [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/). ## Shared domain values These standalone examples use `ConceptAs`, not Chronicle event-source identities. Keep each declaration in its own application file. The named types prevent passing a cart id where a customer id is expected: ```csharp using Cratis.Concepts; public record CustomerId(Guid Value) : ConceptAs(Value) { public static readonly CustomerId NotSet = new(Guid.Empty); public static CustomerId New() => new(Guid.NewGuid()); public static implicit operator CustomerId(Guid value) => new(value); } public record CustomerName(string Value) : ConceptAs(Value) { public static readonly CustomerName NotSet = new(string.Empty); public static implicit operator CustomerName(string value) => new(value); } public record CartId(Guid Value) : ConceptAs(Value) { public static readonly CartId NotSet = new(Guid.Empty); public static CartId New() => new(Guid.NewGuid()); public static implicit operator CartId(Guid value) => new(value); } public record ItemId(Guid Value) : ConceptAs(Value) { public static readonly ItemId NotSet = new(Guid.Empty); public static ItemId New() => new(Guid.NewGuid()); public static implicit operator ItemId(Guid value) => new(value); } public record CustomerRenamed(CustomerId CustomerId, CustomerName Name); ``` ## Entity Framework Core A `[ReadModel]` entity carried by a `ReadOnlyDbContext` becomes injectable once the context is registered — there is nothing extra to wire up: ```csharp [ReadModel] public class Customer { public required CustomerId Id { get; set; } public required CustomerName Name { get; set; } } public class CustomerDbContext(DbContextOptions options) : ReadOnlyDbContext(options) { public DbSet Customers => Set(); } ``` ```csharp using System.ComponentModel.DataAnnotations; using Cratis.Arc.Commands.ModelBound; [Command] public record RenameCustomer([property: Key] CustomerId CustomerId, CustomerName NewName) { public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName); } ``` `WithEntityFrameworkCore()` discovers the `ReadOnlyDbContext`, and the command’s resolved key (here the `[Key]` on `CustomerId`) loads the entity by its primary key. The primary key may be a `Guid`, `int`, `long`, `string`, or a `ConceptAs` wrapping one of those. The nullable rules are identical: a nullable `Customer?` receives `null` when no row exists, and a non-nullable `Customer` fails the command with [`ReadModelDoesNotExistForCommand`](/arc/backend/csharp/chronicle/read-models/failures/#readmodeldoesnotexistforcommand). ## MongoDB `WithMongoDB()` does the same for the read models MongoDB holds. There is nothing to declare — a `[ReadModel]` becomes injectable, resolved by the document `_id`: ```csharp [ReadModel] public record Customer(CustomerId Id, CustomerName Name) { public static IEnumerable AllCustomers(IMongoCollection collection) => collection.Find(_ => true).ToList(); } ``` ```csharp using System.ComponentModel.DataAnnotations; using Cratis.Arc.Commands.ModelBound; [Command] public record RenameCustomer([property: Key] CustomerId CustomerId, CustomerName NewName) { public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName); } ``` The id member is whichever one MongoDB maps to `_id` — a member named `Id` by convention, or the one marked `[BsonId]`. Like the EF primary key it may be a `Guid`, `int`, `long`, `string`, or a `ConceptAs` wrapping one of those. A read model with no member mapped to `_id` cannot be resolved by key, and injecting it fails with `MissingIdMapping`. ## Which provider resolves a read model When a fallback and a declaring provider can both load a read model, declared ownership wins. Avoid having two providers declare the same CLR type: that conflict is registration-order-dependent. | Provider | Owns a read model when | Claims it as | | --- | --- | --- | | Chronicle | a projection, model-bound projection, or classic reducer targets it, or Chronicle discovers it as an eligible self-reducing model (19.58.0+) | declared | | Entity Framework Core | a `DbSet` on a `ReadOnlyDbContext` carries it | declared | | MongoDB | — a collection is served for any read model | fallback | A declaring provider wins over a fallback in either registration order. Two declaring providers (for example Chronicle and EF Core) replace one another; the later registration wins. MongoDB claims only what nothing else resolves, and it also leaves your own registration of a read model type alone. This matters beyond tidiness: Chronicle is the provider that releases a read model’s compliance-protected values, so a Chronicle-backed model, including a [self-reducing model](/arc/backend/csharp/chronicle/read-models/#self-reducing-read-models), has to be resolved by Chronicle. Being listed in the reducer artifacts is not enough on its own: a nominated plain reducer implementation is not necessarily its own read model. ### What else the winner decides The provider that claims a read model also decides which serialization boundary the injected instance crosses, and the three cross entirely different ones: | Provider | Materializes a command-side read model through | | --- | --- | | Chronicle | ordinarily a service JSON payload deserialized with `System.Text.Json`; passive reducers can fold state in-process | | Entity Framework Core | its own entity model | | MongoDB | the driver’s `BsonClassMap` and convention machinery | So whatever customization belongs to one of those boundaries — a convention pack, a class-map customization, an element rename, a custom serializer, a JSON converter — reaches a command-side read model only when its own provider is the one that claimed it. Chronicle and Entity Framework Core both declare. For each type claimed by either, MongoDB’s fallback does not supply the command-side instance. Its BSON customization therefore does not govern that instance. Other types in the same application can still be MongoDB-owned. See [materialized and passive paths](/arc/backend/csharp/chronicle/read-models/#materialized-and-passive-paths). The same customization can be plainly at work on the query side A convention registered through `ICanProvideMongoDBConventionPacks` goes into the driver’s global registry, so it applies wherever the driver materializes a read model — which includes queries served from an `IMongoCollection`. Seeing it work there says nothing about the command side, and this is the shape the failure takes: the customization looks discovered and correct, because the surface anybody checks first is the one it does reach. To contribute a provider of your own, implement `ICanResolveReadModelForCommand` — reporting the types it resolves, the `ReadModelForCommandOwnership` it claims them with, and how to load one by key — and register it with `services.AddReadModelsForCommand(...)`. ## Declaring the key without Chronicle Every provider loads by the resolved command key. When the Chronicle integration is registered, its resolver supplies that key through `ICanProvideEventSourceId`, an `EventSourceId`/`EventSourceId`-derived property, or Chronicle `[Key]`. Otherwise Arc uses its provider-neutral key rules. An application without Chronicle has none of those, so Arc reads the key from the command itself. Mark the property holding it with the data annotations `[Key]`: ```csharp using System.ComponentModel.DataAnnotations; [Command] public record RenameCustomer([property: Key] CustomerId CustomerId, CustomerName NewName) { public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName); } ``` The key may be a `Guid`, `int`, `long`, `string`, or a `ConceptAs` wrapping one of those — a concept resolves to the value it wraps rather than to its own `ToString()`. When the key is not one property — a composite of two, or a value derived from them — the command declares it: ```csharp [Command] public record FindItem(CartId CartId, ItemId ItemId) : ICanProvideKeyForCommand { public string GetKey() => $"{CartId.Value}/{ItemId.Value}"; public bool Handle(CartItem? item) => item is not null; } ``` The composite-key fragment assumes an application `CartItem` registered with a matching string key and imports from `Cratis.Arc.Commands` and `Cratis.Arc.Commands.ModelBound`. Without Chronicle, nothing is inferred from the shape of a command. One carrying two identifiers and marking neither resolves no key, and injection fails as a validation error rather than silently picking one of them. To key commands your own way across an application, implement `ICanResolveKeyForCommand`. It is discovered automatically and asked before the rule Arc ships, whichever order the two happen to be discovered in. Two attributes are spelled `[Key]` In an application **with** Chronicle, the data annotations `[Key]` is not used by the Chronicle resolver. If no other provider or recognized identity property supplies a key, Chronicle generates a fresh event source id; a lookup for existing state then usually finds nothing. [ARCCHR0008](/arc/backend/csharp/chronicle/code-analysis/arcchr0008/) reports it, so this is a build warning rather than a puzzling “the entity does not exist” at runtime. ## See also * [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) — where to declare the dependency and what nullability means. * [When resolution fails](/arc/backend/csharp/chronicle/read-models/failures/) — every error and what it means. * [Arc without event sourcing](/arc/arc-without-event-sourcing/) — the whole slice, with the data stored straight in a collection. # Resolving EventSourceId > How Arc's optional Chronicle integration selects command identity, and why response identity and query binding are separate stages. The Chronicle integration selects an `EventSourceId` before a model-bound command runs. That identity selects its command-scoped read models and aggregate roots, and supplies the default target for returned events. It is not inferred from the authenticated user. ## Resolution order for commands 1. An `ICanProvideEventSourceId` implementation supplies the identity explicitly. 2. Otherwise, Arc selects the first matching public property: an `EventSourceId`, an `EventSourceId`-derived type, or a property carrying Chronicle’s `[Key]`. A matching positional constructor parameter can carry `[Key]` too. 3. With no candidate, Arc generates a new identity for creation commands. A declared but unusable key is different: it can resolve to `EventSourceId.Unspecified` and fail dependency resolution. There is **no typed-property-before-keyed-property precedence**. Declare exactly one candidate or implement the provider. An arbitrary implicit conversion on a `ConceptAs` does not make it a runtime identity candidate. Use a domain identity derived from `EventSourceId` instead. The following is a complete command/type example; host registration and persistence configuration are described in [Cratis package](/arc/backend/csharp/chronicle/cratis-package/). ```csharp using System; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Concepts; public record AccountId(Guid Value) : EventSourceId(Value) { public static readonly AccountId NotSet = new(Guid.Empty); public static AccountId New() => new(Guid.NewGuid()); public static implicit operator AccountId(Guid value) => new(value); } public record OwnerName(string Value) : ConceptAs(Value) { public static readonly OwnerName NotSet = new(string.Empty); public static implicit operator OwnerName(string value) => new(value); } [Command] public record OpenAccount(AccountId AccountId, OwnerName OwnerName) { public AccountOpened Handle() => new(OwnerName); } /// /// Records that an account was opened with its initial owner name. /// [EventType] public record AccountOpened(OwnerName OwnerName); ``` For a legacy primitive key, mark the command property with `Cratis.Chronicle.Keys.KeyAttribute`. Do not substitute `System.ComponentModel.DataAnnotations.KeyAttribute`; that is the provider-neutral Arc key convention, not the Chronicle resolver’s attribute. ## Input identity versus response identity An `EventSourceId` or `EventSourceId` response can override the default target for **return-driven appends** after `Handle()` finishes. It cannot retroactively reload a validator’s read model, retarget an injected aggregate, or move events the aggregate already enrolled under its input identity. A raw `Guid` remains an ordinary response. A keyless command can legitimately return an unrelated confirmation Guid while events use the generated fallback. [ARCCHR0010](/arc/backend/csharp/chronicle/code-analysis/arcchr0010/) is a heuristic warning to check that intent, not a runtime prohibition. See [Returning EventSourceId](/arc/backend/csharp/chronicle/commands/returning-event-source-id/). ## Query arguments are ordinary Arc binding Ordinary model-bound queries bind arguments by name and convert them to their declared types. They do **not** invoke the Chronicle command-key resolver. A typed id helps conversion and type safety; `[Key]` on a query argument does not select a read model automatically. Reuse `AccountId` and `OwnerName` from the command example so the query preserves the same domain vocabulary. This query/type fragment assumes the MongoDB integration is registered. The filter, not a command-key convention, selects the document: ```csharp using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; [ReadModel] public record AccountOverview(AccountId Id, OwnerName OwnerName) { public static AccountOverview? ById( AccountId id, IMongoCollection collection) => collection.Find(account => account.Id == id).FirstOrDefault(); } ``` ## Related references * [Command context values](/arc/backend/csharp/commands/command-context/#command-context-values) * [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) * [Aggregate roots in commands](/arc/backend/csharp/chronicle/aggregates/injecting-into-commands/) * [Setting Subject](/arc/backend/csharp/chronicle/commands/subject/) — compliance identity is separate from stream identity. # Tenancy > Map Arc's tenant context to Chronicle namespaces, and configure access policy and other storage providers separately. The usual way multi-tenancy goes wrong is a forgotten `WHERE TenantId = ...`. One query misses the filter and one tenant sees another’s data. The defense is discipline, applied everywhere, forever. Chronicle removes the filter instead of asking you to remember it. Each tenant gets its own **namespace** — a separate event store partition — so there is no shared table to accidentally read across. Arc wires the two together: whatever resolved Arc’s tenant for the current request also picks the Chronicle namespace, automatically. ## What you write Adding the Chronicle integration registers `TenantNamespaceResolver`. Namespace-aware Chronicle client operations use that mapping. It does not add tenant filters to arbitrary query code or independently configured databases. This command fragment assumes a supported `AuthorId : EventSourceId` and an `AuthorRegistered` event: ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public AuthorRegistered Handle() => new(Name); } ``` That command is tenant-aware. There is no tenant parameter, no filter, and no namespace argument — the event lands in the namespace belonging to whoever made the request. For off-request work, [select the tenant before the pipeline creates its DI scope](/arc/backend/csharp/commands/command-pipeline/#execute-for-a-specific-tenant); Chronicle binds its scoped event store to the namespace when the service is resolved. ## The mapping rule `TenantNamespaceResolver` implements Chronicle’s `IEventStoreNamespaceResolver` and reads Arc’s current tenant: | Arc tenant context | Chronicle namespace | | --- | --- | | A tenant is resolved | the tenant id, used verbatim as the namespace name | | Default or unset tenant (`TenantId.Default` or `TenantId.NotSet`) | `EventStoreNamespaceName.Default` | The fallback does not reject missing tenants; it deliberately selects the default namespace. Single-tenant applications can use that mapping. Multi-tenant applications must decide whether access to default-namespace data is allowed and reject missing/invalid tenant context when it is not. This mapping alone is neither proof of a cross-tenant leak nor a complete isolation policy. For direct MongoDB or EF queries, configure the corresponding provider’s tenancy and align it with the Chronicle sink. A shared CLR read-model type does not make an arbitrary database query namespace-aware. Isolation follows the tenant resolver, not Chronicle Chronicle isolates by whatever tenant id Arc hands it. If the resolver picks the wrong tenant — a spoofable header, say — Chronicle will faithfully write to the wrong namespace. The security boundary lives in how the tenant is resolved, so choose that resolver deliberately. ## Configure how the tenant is resolved Chronicle consumes Arc’s tenant resolution rather than defining its own, so the interesting decision — where the tenant comes from — is an Arc-level one: a claim, a header, a subdomain, or a custom resolver. See [Tenancy](/arc/backend/csharp/tenancy/) for the resolver choices and configuration, and [Namespaces](/chronicle/namespaces/) for what a Chronicle namespace is and how it partitions the event store. ## Where the namespace’s data lives The mapping decides the namespace. The Chronicle kernel decides which database, server and credentials back it, by default or through its own connection resolution. [Tenancy end to end](/arc/backend/csharp/tenancy/end-to-end/#chronicle-the-kernel-decides-its-own-placement) shows how that lines up with Arc’s MongoDB and EF Core, and Chronicle’s [Tenancy](/chronicle/tenancy/) covers the kernel side. ## Request unit-of-work binding `WithChronicle()` preserves Chronicle’s scoped, deferred request unit-of-work manager. Resolving the manager or beginning a unit of work does not resolve the event store or select its namespace. The first event-store operation binds the unit to the tenant resolved at that point. An empty request never needs a store. For claim-based tenancy, authenticate the request before performing event-store operations. Middleware may begin the unit of work before authentication; beginning it alone no longer caches an anonymous tenant. Once the store has been used, the unit remains bound to that tenant for the rest of its lifetime. An explicitly registered `IUnitOfWorkManager` still takes precedence. Custom managers are responsible for their own tenant binding and lifecycle behavior. # Validation with read models > Validate an event-sourced command against the state Chronicle already projected for its key, without writing a query. Arc’s [validation model](/arc/backend/csharp/commands/validation/) — data annotations, `ConceptValidator`, and `CommandValidator` — works unchanged when Chronicle is in the picture. What the integration adds is one thing: a validator can take the **read model Chronicle projected for the command’s key** as a constructor dependency, and validate against current state without a query. ```csharp public class SettleLedgerValidator : CommandValidator { public SettleLedgerValidator(LedgerBalance balance) => RuleFor(command => command.LedgerId) .Must(_ => balance.Balance > 0) .WithMessage("Ledger has no funds to settle."); } ``` This validator fragment assumes the application’s `SettleLedger` command and `LedgerBalance` backing. Arc resolves the model using the command’s input-time event source id, then constructs the validator. A later returned identity cannot reload that dependency. The flow is: 1. The command is bound, and its event source id is resolved — from `[Key]`, from an `EventSourceId` or `EventSourceId`-derived property, or from `ICanProvideEventSourceId`. See [Resolving EventSourceId](/arc/backend/csharp/chronicle/resolving-event-source-id/), which contributes the value to the [Command Context Values](/arc/backend/csharp/commands/command-context/#command-context-values). 2. The read model instance is loaded from Chronicle’s read model store by that id. 3. Validators are constructed with it and their rules run — before `Handle()` is invoked. Because the same command scope serves `Provide()` and `Handle()`, all three see the same instance. ## What to be aware of * **A key does not prove existence.** Declare the parameter nullable when a missing projection is a business condition, non-nullable when it is required. This is the central decision — see [nullable versus required](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/#nullable-means-you-handle-absence). * **Freshness depends on backing, and on the mode.** Under read protection `Off`, materialized models can lag and passive models build state on demand, and neither is a guard: a validator that reads a plain read model can pass for two concurrent commands. Under `On`, the validator’s read-model parameter is read fresh and guarded, so a state rule belongs in the validator. Use a Chronicle [constraint](/chronicle/constraints/) for uniqueness. See [validators and read protection](/arc/backend/csharp/chronicle/consistency/validators/). * **Validators need the Arc command pipeline.** Read-model injection does not work through MVC controllers — see [`ReadModelValidatorRequiresCommandPipeline`](/arc/backend/csharp/chronicle/read-models/failures/#readmodelvalidatorrequirescommandpipeline). ## See also * [Choosing how a command’s rule holds](/arc/backend/csharp/chronicle/consistency/) — which mechanism makes a rule hold, and which only look as if they do. * [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) — the recipe, covering validators, `Provide()`, and `Handle()`. * [Read models in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) — the full reference for all three positions. * [Command validation](/arc/backend/csharp/commands/validation/) — Arc’s validation model in general. # Code analysis rules > Core Arc diagnostic inventory, default severities, analyzer packaging, and available editor fixes. Arc’s Core Roslyn analyzers check command, query, validation, and concept declarations at compile time. They do not require Chronicle. The model-graph generator also reports [ARC0030](/arc/backend/csharp/code-analysis/arc0030/) for composite protected-read keys when Chronicle’s accessor registry is referenced, and [ARC0031](/arc/backend/csharp/code-analysis/arc0031/) for commands whose key properties cannot be generated. The optional [Chronicle integration rules](/arc/backend/csharp/chronicle/code-analysis/) have a separate `ARCCHR` prefix. ## Rules overview These are the active `ARC` descriptors in the current source, all enabled by default in category `Arc`. Severity is the default before your project’s analyzer configuration. The release-tracking file currently lists ARC0001–ARC0031 under **Unshipped**; its Shipped table has no rule entries. This inventory describes the current source, not a claim that every older NuGet version contains every rule. | Rule ID | Descriptor title | Severity | Analyzer | | --- | --- | --- | --- | | [ARC0001](/arc/backend/csharp/code-analysis/arc0001/) | Incorrect Query method signature on ReadModel | Error | `ReadModelAnalyzer` | | [ARC0002](/arc/backend/csharp/code-analysis/arc0002/) | Missing \[Command] attribute on command-like type | Warning | `CommandAnalyzer` | | [ARC0003](/arc/backend/csharp/code-analysis/arc0003/) | Handle() must be on \[Command] type | Error | `CommandAnalyzer` | | [ARC0004](/arc/backend/csharp/code-analysis/arc0004/) | \[Command] type must have public Handle() method | Error | `CommandAnalyzer` | | [ARC0005](/arc/backend/csharp/code-analysis/arc0005/) | Value produced by Provide is not consumed by Handle | Warning | `CommandProvideAnalyzer` | | [ARC0006](/arc/backend/csharp/code-analysis/arc0006/) | Command-scoped read model can be missing | Warning | `InjectedReadModelAnalyzer` | | [ARC0007](#arc0007-command-records) | Command should be declared as a record | Warning | `ModelBoundRecordAnalyzer` | | [ARC0008](#arc0008-read-model-records) | ReadModel should be declared as a record | Warning | `ModelBoundRecordAnalyzer` | | [ARC0009](#arc0009-concept-records) | Concept should be declared as a record | Warning | `ConceptRecordAnalyzer` | | [ARC0010](#arc0010-synchronous-handlers) | Command Handle() wraps a synchronous result in a Task | Warning | `CommandHandleTaskWrappingAnalyzer` | | [ARC0011](#arc0011-role-names) | \[Roles] argument should use nameof instead of a string literal | Warning | `RolesLiteralAnalyzer` | | [ARC0012](#arc0012-domain-named-exceptions) | Arc artifact throws a built-in exception type | Warning | `ArcArtifactBuiltInExceptionAnalyzer` | | [ARC0013](#arc0013-concept-dereferences-in-validators) | Validator rule dereferences a possibly-null concept member | Warning | `ValidatorConceptDereferenceAnalyzer` | | [ARC0014](#arc0014-generic-query-methods) | Generic query method on ReadModel | Error | `ReadModelAnalyzer` | | [ARC0015](#arc0015-concept-query-parameters) | Query parameter converted to a concept in the method body | Warning | `QueryParameterConceptTypeAnalyzer` | | [ARC0016](#arc0016-command-operation-methods) | Invalid command operation method | Error | `CommandOperationAnalyzer` | | [ARC0017](#arc0017-command-operation-batches) | Use CommandOperations for operation batches | Error | `CommandOperationAnalyzer` | | [ARC0018](#arc0018-command-operation-visibility) | Operation cannot have a generated invoker | Error | `CommandOperationAnalyzer` | | [ARC0019](#arc0019-conflicting-authorization) | \[AllowAnonymous] conflicts with \[Authorize] or \[Roles] on the same declaration | Warning | `AuthorizationAttributeAnalyzer` | | [ARC0020](#arc0020-aspnet-core-authorization-attributes) | ASP.NET Core authorization attribute is not enforced without Arc’s ASP.NET Core integration | Warning | `AuthorizationAttributeAnalyzer` | | [ARC0021](#arc0021-unevaluated-authorization-settings) | Authentication scheme requires the ASP.NET Core host | Warning | `AuthorizationAttributeAnalyzer` | | [ARC0022](/arc/backend/csharp/code-analysis/arc0022/) | Invalid generated value parameter type | Error | `GeneratedValueAnalyzer` | | [ARC0023](/arc/backend/csharp/code-analysis/arc0023/) | Generated value declared outside a command Handle method | Error | `GeneratedValueAnalyzer` | | [ARC0024](/arc/backend/csharp/code-analysis/arc0024/) | Generated value names must be non-empty and unique | Error | `GeneratedValueAnalyzer` | | [ARC0025](/arc/backend/csharp/code-analysis/arc0025/) | Provide supplies a generated value type | Warning | `GeneratedValueAnalyzer` | | [ARC0026](/arc/backend/csharp/code-analysis/arc0026/) | Invalid explicit query declaration | Error | `ReadModelAnalyzer` and query metadata generation | | [ARC0027](/arc/backend/csharp/code-analysis/arc0027/) | Generated value implements both generation contracts | Warning | `GeneratedValueAnalyzer` | | [ARC0028](/arc/backend/csharp/code-analysis/arc0028/) | Move inline generation to a generated parameter | Info | `InlineGeneratedValueAnalyzer` | | [ARC0029](/arc/backend/csharp/code-analysis/arc0029/) | Non-public static method is not exposed as a query | Warning | `NonPublicQueryAnalyzer` | | [ARC0030](/arc/backend/csharp/code-analysis/arc0030/) | Composite key requires accessor registration | Info | `ModelGraphWalkerGenerator` | | [ARC0031](/arc/backend/csharp/code-analysis/arc0031/) | Command key properties are read through reflection | Info | `ModelGraphWalkerGenerator` | The individual rule pages contain deliberately invalid **diagnostic examples**, not runnable application checkpoints. Compile each alternative separately; duplicate domain type names are intentional. ## ARC0007: Command records Reports a non-record type marked `[Command]`. Declare the command as a record and retain its public instance `Handle()` method. This is a modeling warning, not a claim that the runtime rejects every class-based command. ## ARC0008: Read model records Reports a non-record type marked `[ReadModel]`. Prefer a record to represent returned state with value equality. A standalone Arc read model need not be an event-sourced projection. ## ARC0009: Concept records Reports a non-record class deriving from `Cratis.Concepts.ConceptAs`, including indirect inheritance. Use a record. Since `ConceptAs` is itself a record, an ordinary derived class also violates the C# record inheritance rules; this diagnostic can accompany a compiler error. ## ARC0010: Synchronous handlers Reports a public instance `Handle()` on a `[Command]` that returns `Task` or `Task` and either: * is `async` but has no own `await`, or * returns only `Task.FromResult(...)` / `Task.CompletedTask` wrappers. Return the synchronous value (or `void`) directly. Forwarding a genuine asynchronous operation is not the same as wrapping a synchronous value. Ordinary response DTOs do not need Chronicle. The editor offers **Unwrap to synchronous Handle()**; an `async` example without `await` may also produce compiler warning CS1998. ## ARC0011: Role names Reports direct string-literal arguments to Arc’s `[Roles]` attribute. Prefer `nameof(ApplicationRole.Administrator)` when that enum member defines the role’s actual wire name. Do not change an externally assigned role string merely to silence the analyzer. The editor offers **Use nameof for role** only when it finds a matching enum member. The current fix searches by member name and inserts an unqualified enum type name; review the selected enum and its namespace, especially when several enums share a member name. It does not prove that your identity provider issues that role. ## ARC0012: Domain-named exceptions Reports explicit `throw new ...` of an exception in `System` or a `System.*` namespace from: * a `[Command]` type’s `Handle()` method, * a `CommandValidator` or `ConceptValidator` type, * a Chronicle `IReactor` implementation, if the optional integration is present. Use validation rejection for expected invalid input. For genuinely exceptional failures, use a domain-named exception. Simply renaming an exception does **not** turn it into a validation result. For expected invalid input, return an explicit validation result rather than throwing an ordinary domain exception. ## ARC0013: Concept dereferences in validators Reports a FluentValidation `RuleFor` selector that dereferences a member of a concept property, such as `RuleFor(command => command.Name.Value)`. Input can contain a null concept even when its declaration is non-nullable. Prefer validating the concept itself and placing its invariant in a `ConceptValidator`; use a null guard when checking an optional concept’s member. **Current limitation:** detection is syntactic after identifying the concept type. It does not analyze a surrounding `.When(...)`, rule ordering, or cascade settings. A correctly guarded member selector can still be reported. Review the guard before applying a narrowly scoped suppression; do not remove required validation to obtain a clean build. ## ARC0014: Generic query methods Reports public static generic methods on a `[ReadModel]` whose return type has an accepted query shape. Methods with a parameter of their own read-model type, nullable or not, are excluded; generic self-parameter helpers are not query candidates. Query invocation cannot close a genuine query’s type parameters. Make the query non-generic, move a generic composition helper off the read model, or opt into explicit query selection and leave the helper unmarked. A valid return type alone does not make a generic method invocable. ## ARC0015: Concept query parameters Reports `string`, `Guid`, or nullable `Guid` parameters converted to a `ConceptAs`-derived type inside a public static non-void method on a `[ReadModel]`. Self-parameter methods are never queries and are ignored. In explicit mode only selected queries are analyzed; unmarked helpers are ignored. The analyzer recognizes conversion operations (including implicit conversions), not every possible manual construction or data-flow pattern. Declare the parameter as the concept when you want Arc’s query pipeline to validate that concept before invoking the method. Preserve null-aware handling for omitted input. For free-text search whose accepted values intentionally differ from an identifier concept’s rules, review the model or suppress this warning locally with a reason. Direct static calls still bypass the query pipeline. ## ARC0016: Command operation methods Reports an `ICommandOperation` implementation without exactly one valid public instance `Execute()` or with an invalid optional `Compensate()`. Both methods must be nongeneric and return `void`, `Task`, or `ValueTask`. Unsupported shapes include `async void`, value-returning tasks, static or overloaded methods, by-reference arguments, optional service parameters, and service locators. `CommandOperationFailure` can be requested only by `Compensate()`. Use the [operation declaration contract](/arc/backend/csharp/commands/operations/reference/#declaration-contract). Forward service calls directly; do not add a separate executor type or application `try/catch` merely to satisfy the convention. ## ARC0017: Command operation batches Reports a command return shape containing a bare collection of operation values, including arrays and typed enumerables inside supported wrappers. Return `CommandOperations` instead. The explicit immutable batch distinguishes server execution from an ordinary collection response and supports collection expressions such as `[]`. See [zero-to-many operations](/arc/backend/csharp/commands/operations/implementing/#return-zero-or-many-operations). Do not work around the diagnostic by erasing operation types to `object`; use a meaningful declared return contract. ## ARC0018: Command operation visibility Reports a concrete operation whose type cannot be referenced by its generated invoker. Use a public or internal nongeneric operation in accessible nongeneric containing types. File-local, private nested, or generic declarations do not provide the supported generated invocation shape. A file-local type cannot be referenced from the separate generated source file. Runtime validation remains necessary when declarations are loaded without the source generator. Generated operation invokers do not establish NativeAOT support for every other Arc execution path. ## ARC0019: Conflicting authorization Reports `[AllowAnonymous]` declared on the same class or method as `[Authorize]` or `[Roles]`. One declaration that both opens itself to anonymous callers and restricts itself to authenticated or role-holding ones has no defensible reading. Arc refuses to guess: at run time it rejects the call with `AmbiguousAuthorizationLevel`, whichever attribute families are involved. The analyzer reports the contradiction at build time instead of on the first request. Keep one of the two. ```csharp [Command] [Authorize] [AllowAnonymous] // ARC0019 public record OpenAccount(string Name) { public void Handle() { } } ``` Overriding a class across two declarations is a different, supported thing and is not reported: an `[Authorize]` read model can mark one query method `[AllowAnonymous]`, which is how a protected read model exposes a login-screen query. Both Arc’s and ASP.NET Core’s attributes are recognized, but a declaration using only ASP.NET Core’s attributes on a type that is not an Arc artifact, such as an MVC controller, is left to ASP.NET Core. ## ARC0020: ASP.NET Core authorization attributes Reports `[Authorize]` or `[AllowAnonymous]` from `Microsoft.AspNetCore.Authorization` on a `[Command]` or `[ReadModel]` type or method, in a project that does not reference Arc’s ASP.NET Core integration (`Cratis.Arc`). That integration is what enforces the ASP.NET Core attributes on model-bound artifacts. Without it - on Arc Core’s own HTTP host, for example - nothing reads them, and a command marked with one stays open to every caller. ```csharp using Microsoft.AspNetCore.Authorization; [Command] [Authorize] // ARC0020 in a project without Cratis.Arc: this command is not protected public record CloseAccount(string Id) { public void Handle() { } } ``` Use the Arc attribute of the same name, `Cratis.Arc.Authorization.AuthorizeAttribute` or `AllowAnonymousAttribute`, which every Arc host enforces. When a file imports both namespaces, qualify the attribute or alias it. Controllers are not reported; ASP.NET Core MVC enforces its own attributes there. Arc v18.2.0 through v22.20.0 did not enforce the ASP.NET Core attributes on model-bound artifacts even with `Cratis.Arc`, because its evaluators resolved Arc’s same-named attributes instead ([#2719](https://github.com/Cratis/Arc/issues/2719)). ## ARC0021: Unevaluated authorization settings Reports `AuthenticationSchemes` on an authorization attribute of a `[Command]` or `[ReadModel]` type or method when the project does not reference Arc’s ASP.NET Core integration. Native named policies are evaluated asynchronously on either Arc host. Explicit schemes require ASP.NET Core’s authentication service; the standalone Core host fails startup rather than silently admitting a caller. ```csharp [Command] [Authorize(AuthenticationSchemes = "Bearer")] // ARC0021 without Cratis.Arc public record UpdateProfile(string Name) { public void Handle() { } } ``` Use [ASP.NET Core integration](/arc/backend/csharp/asp-net-core/authorization/) and register the scheme, or omit `AuthenticationSchemes` and use a [host-neutral named policy](/arc/backend/csharp/core/authorization/#register-a-named-policy). Missing or ambiguous policy registrations also fail startup. Controllers are not reported; ASP.NET Core MVC evaluates their policies and schemes independently. ## Quick fixes Only these Core diagnostics currently have code-fix providers. The other rules require manual changes. | Rule | Editor action | Provider | | --- | --- | --- | | ARC0010 | Unwrap to synchronous Handle() | `UnwrapCommandHandleTaskCodeFixProvider` | | ARC0011 | Use nameof for role | `UseNameofForRolesCodeFixProvider` | | [ARC0028](/arc/backend/csharp/code-analysis/arc0028/) | Move to a \[GeneratedValue] parameter | `MoveToGeneratedValueCodeFixProvider` | ARC0010 and ARC0011 expose Roslyn’s batch Fix All support; ARC0028 deliberately fixes one expression at a time. Review and compile the result, particularly the enum lookup for ARC0011. ## Installation The `Cratis.Arc.Core` NuGet package depends on `Cratis.Arc.Core.CodeAnalysis`. Install Core normally; no Chronicle package is needed: ```bash dotnet add package Cratis.Arc.Core ``` The analyzer package places `Cratis.Arc.Core.CodeAnalysis.dll` and `Cratis.Arc.Core.CodeAnalysis.CodeFixes.dll` under `analyzers/dotnet/cs`. Compiler analysis and workspace-based editor fixes live in separate assemblies. Arc Core’s source generators are separate assets; `ModelGraphWalkerGenerator` supplies ARC0030 for the optional Chronicle accessor registry. Editor support and the installed package version determine which fixes you see. For source contributors, the inventory is defined by `Source/DotNET/Arc.Core.CodeAnalysis/DiagnosticDescriptors.cs`, each analyzer’s `SupportedDiagnostics`, `AnalyzerReleases.*.md`, and `Arc.Core.CodeAnalysis.Package/Arc.Core.CodeAnalysis.Package.csproj`. Check all four when adding a rule; a descriptor alone does not demonstrate that a diagnostic runs or is packaged. # ARC0001: Incorrect query method signature on ReadModel > Return the read model itself or an accepted wrapper from a model-bound query. []()[]() ## Rule Reports public static non-void methods on `[ReadModel]` types whose return type is not the read model or an accepted wrapper. Methods accepting their own read-model type, nullable or not, are excluded from query discovery and this diagnostic, regardless of parameter name or position. **Default severity: Error.** Move unrelated utility methods off the read model rather than exposing them as queries, or use [explicit endpoint selection](/arc/backend/csharp/queries/model-bound/static-methods/#choose-endpoints-explicitly-when-a-model-has-helpers). []() ## Accepted return shapes For read model `T`, the analyzer accepts: * `T` and `T[]` * `IEnumerable` and types implementing it, including `List` and `IQueryable` * `Task` and `Task` where `C` is an accepted collection of `T` * `IAsyncEnumerable` * `ISubject` and `ISubject` where `C` is an accepted collection of `T` These are type shapes, not declarations to paste into a file. A nongeneric `Task` and `ValueTask` are not accepted by this analyzer. For transport behavior, see [query return types](/arc/backend/csharp/queries/model-bound/return-types/). []()[]() ## Diagnostic example Compile each block separately with `Cratis.Arc.Core`. The first intentionally produces ARC0001; it is not a runnable application. ```csharp using System; using Cratis.Arc.Queries.ModelBound; [ReadModel] public record User(Guid Id, string Name) { public static string GetName(Guid id) => "Ada"; } ``` []() Return the read model instead: ```csharp using System; using Cratis.Arc.Queries.ModelBound; [ReadModel] public record User(Guid Id, string Name) { public static User GetById(Guid id) => new(id, "Ada"); } ``` This fixed declaration returns sample data; it does not demonstrate database persistence or host setup. Both declarations use records so ARC0008 does not distract from the return-type error. ## Related rules * [ARC0014: Generic query methods](/arc/backend/csharp/code-analysis/#arc0014-generic-query-methods) * [ARC0002: Missing command attribute](/arc/backend/csharp/code-analysis/arc0002/) # ARC0002: Missing [Command] attribute on command-like type > Mark a command-shaped type for model-bound discovery or remove the command-shaped method. []() ## Rule The analyzer uses a heuristic: a type with a public property and an instance `Handle` method looks like a command. If it lacks `[Command]`, it reports a warning. This does not mean every method named `Handle` belongs to Arc; rename the method if the type is not intended to be a command. **Default severity: Warning.** []()[]() ## Diagnostic example Compile these alternatives separately with `Cratis.Arc.Core`. The first deliberately produces ARC0002; these are declaration examples, not runnable hosts. ```csharp public record NormalizeName(string Name) { public string Handle() => Name.Trim(); } ``` []() Add the model-bound attribute: ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record NormalizeName(string Name) { public string Handle() => Name.Trim(); } ``` []() The handler returns an ordinary string response. No event-sourcing integration is needed. If the attribute is absent, Arc does not discover this as a model-bound command. ## Related rules * [ARC0001: Query return type](/arc/backend/csharp/code-analysis/arc0001/) * [ARC0004: Public instance Handle method](/arc/backend/csharp/code-analysis/arc0004/) # ARC0003: Handle() must be on [Command] type > Keep a model-bound command's handler on the command rather than in an external handler class. []() ## Rule Reports a public instance `Handle()` method on a non-command type when a parameter is a `[Command]` type. Move that behavior onto the command itself. **Default severity: Error.** []()[]() ## Diagnostic example Compile these alternatives separately with `Cratis.Arc.Core`. The external handler deliberately triggers ARC0003. The command already has `Handle()` to avoid an unrelated ARC0004 diagnostic; it returns sample data, not persisted state. ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record NormalizeName(string Name) { public string Handle() => Name; } public class NormalizeNameHandler { public string Handle(NormalizeName command) => command.Name.Trim(); } ``` []() Remove the external handler and put its behavior on the command: ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record NormalizeName(string Name) { public string Handle() => Name.Trim(); } ``` These declarations illustrate handler placement; they are not complete host programs. See [model-bound commands](/arc/backend/csharp/commands/model-bound/) for the execution model. # ARC0004: [Command] type must have public Handle() method > Declare a public instance Handle method on each model-bound command. []() ## Rule A `[Command]` type must declare a public instance `Handle()` method. Missing, non-public, and static handlers do not satisfy this check. **Default severity: Error.** []()[]() ## Diagnostic examples Each block is a separate declaration example for a project referencing `Cratis.Arc.Core`, not a runnable host. The first two intentionally produce ARC0004. Missing handler: ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record NormalizeName(string Name); ``` Non-public handler: ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record NormalizeName(string Name) { internal string Handle() => Name.Trim(); } ``` []() Corrected declaration: ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record NormalizeName(string Name) { public string Handle() => Name.Trim(); } ``` The synchronous string is an ordinary response; a handler need not return an event. See [model-bound commands](/arc/backend/csharp/commands/model-bound/) for supported behavior. # ARC0005: Value produced by Provide is not consumed by Handle > Consume every non-control value produced by a command's Provide method. []() ## Rule When a command declares `Provide()`, each produced value must have a compatible `Handle()` parameter. The analyzer unwraps `Task`, `ValueTask`, tuples, and OneOf-based result alternatives. It accepts identity or implicit conversions to a handler parameter. **Default severity: Warning.** Control values that short-circuit execution instead of feeding `Handle` are exempt: `ValidationResult`, collections of `ValidationResult`, `AuthorizationResult`, and `CommandResult`. []()[]() ## Diagnostic example Compile these alternatives separately with `Cratis.Arc.Core`. They illustrate data flow only; the first intentionally produces ARC0005. ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record NormalizeName(string Name) { public string Provide() => Name.Trim(); public string Handle() => Name; } ``` []() `Provide()` produces a trimmed string, but the handler ignores it. Consume the provided value: ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record NormalizeName(string Name) { public string Provide() => Name.Trim(); public string Handle(string normalizedName) => normalizedName; } ``` The string is an ordinary response, not an event. See [Provide data to a command handler](/arc/scenarios/provide-data-to-a-command/) for dependency-backed examples and short-circuit behavior. # ARC0006: Command-scoped read model can be missing > Choose nullable or required read model dependencies deliberately, and distinguish missing state from missing registration. []() ## Rule A non-nullable read model parameter makes state a required dependency. Make the parameter nullable when the command handles absence as a normal outcome; retain a required parameter when absence should reject the command. **Default severity: Warning.** The analyzer checks: * constructors of `CommandValidator` types, * public instance `Provide()` methods on `[Command]` types, * public instance `Handle()` methods on `[Command]` types. It recognizes types marked `[ReadModel]` and, when Chronicle is referenced, types used by `IProjectionFor` implementations in the current compilation. This compile-time recognition does not register a runtime provider or prove that an instance exists. []()[]() ## Diagnostic example This standalone Arc declaration deliberately produces ARC0006. Compile it in a project referencing `Cratis.Arc.Core` with nullable reference types enabled. It is **not a runnable lookup**: a real application must configure the read model provider and its key resolution. ```csharp #nullable enable using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Queries.ModelBound; using FluentValidation; [Command] public record SubmitOrder(string OrderId) { public string Handle() => OrderId; } [ReadModel] public record OrderState(bool Ready); public class SubmitOrderValidator : CommandValidator { public SubmitOrderValidator(OrderState order) { RuleFor(_ => order.Ready) .Equal(true) .WithMessage("Order must be ready for submission"); } } ``` The command has a public handler and the read model is a record, so neither ARC0004 nor ARC0008 obscures the intended warning. The string identifier does not, by itself, configure command-scoped lookup. []() ## Handle missing state explicitly Replace the validator above with this nullable version when you want the validator itself to report absence: ```csharp public class SubmitOrderValidator : CommandValidator { public SubmitOrderValidator(OrderState? order) { RuleFor(_ => order) .NotNull() .WithMessage("Order does not exist"); When(_ => order is not null, () => { RuleFor(_ => order!.Ready) .Equal(true) .WithMessage("Order must be ready for submission"); }); } } ``` This is a replacement fragment using the first block’s types and imports. The guard prevents dereferencing an absent instance. For creation commands, absence might instead be the desired condition; choose rules for your use case, not merely to silence the warning. []() ## Require existing state Retain the non-nullable parameter when missing state should fail before the validator is constructed. Review or narrowly suppress the warning to record that deliberate choice. Nullable annotations do not fix provider registration or guarantee lookup success. For a registered command-scoped read model provider: | Resolution outcome | Current behavior | | --- | --- | | Usable key, instance absent, nullable parameter | Receives `null`; application logic decides what absence means | | Usable key, instance absent, required parameter | `ReadModelDoesNotExistForCommand`, a validation failure mapped to HTTP 400 | | Provider cannot resolve a usable key | `UnableToResolveReadModelFromCommandContext`, a validation failure mapped to HTTP 400, even for a nullable parameter | | Required dependency is not registered | `CannotResolveCommandDependency` or `CannotResolveValidatorDependency`, an exception outcome rather than a normal missing-state rejection | An unregistered nullable dependency can receive `null` without any lookup. Check registration before treating that as evidence that no state exists. The missing-state/key failures carry `ValidationResultReason.DependencyUnavailable`; [testing assertions](/arc/backend/csharp/testing/command-scenario/#dependency-unavailable-is-not-a-business-rule-rejection) distinguish them from rules that actually ran. []() ## Optional Chronicle behavior With the Chronicle integration, an event-source identifier or `[Key]` selects a projected instance but does not prove it exists. It may never have been created, may have been removed, or may be unavailable during a rebuild. A Chronicle command without a declared identity gets a generated key; that is different from an unusable resolved key. Arc alone does not require projected state. For Chronicle-backed invariants that must hold under concurrency, decide whether projected state is sufficient or whether you need source-of-truth state instead. ## Related documentation * [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) * [Read models in Chronicle commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) * [When Chronicle read model resolution fails](/arc/backend/csharp/chronicle/read-models/failures/) * [Command validation](/arc/backend/csharp/commands/validation/) * [Provide data to a command handler](/arc/scenarios/provide-data-to-a-command/) # ARC0022: Invalid generated value parameter type > Use a non-nullable type implementing IGeneratable for a generated Handle parameter. ## Rule A `[GeneratedValue]` parameter must implement `Cratis.Concepts.IGeneratable` or `IGeneratableInContext` with its own type as `TSelf`. A static `New()` method alone is not enough. Arc rejects a reference parameter whose runtime nullable **read state** is `Nullable`, matching `NullabilityInfoContext`. `Nullable` and by-reference parameters do not satisfy the generation contract. **Default severity: Error.** Read state includes nullable flow attributes: `[MaybeNull]` can make a non-nullable reference parameter invalid, while `[NotNull]` can override a nullable reference annotation. `[AllowNull]` and `[DisallowNull]` affect write state instead and do not change this check. An oblivious reference parameter with unknown read state is not rejected for nullability. Prefer a plainly non-nullable declaration rather than relying on these overrides. ## Diagnostic example Compile these alternatives separately with `Cratis.Arc.Core`. The first intentionally produces ARC0022; `Guid` does not implement the generatable contract. ```csharp using System; using Cratis.Arc.Commands.ModelBound; [Command] public record AllocateCartId { public Guid Handle([GeneratedValue] Guid cartId) => cartId; } ``` ## Fix Declare the generation strategy on a non-nullable identifier type: ```csharp using System; using Cratis.Arc.Commands.ModelBound; using Cratis.Concepts; public record CartId(Guid Value) : ConceptAs(Value), IGeneratable { public static CartId New() => new(GenerateValue.Uuid()); } [Command] public record AllocateCartId { public CartId Handle([GeneratedValue] CartId cartId) => cartId; } ``` Explicit implementations of `IGeneratable.New()` are also supported. Keep client-supplied identifiers on command properties rather than marking them generated. See [generated values](/arc/backend/csharp/commands/generated-values/) for resolution and validation behavior. # ARC0023: Generated value declared outside a command Handle method > Declare generated values only on instance Handle parameters of model-bound commands. ## Rule ARC0023 reports `[GeneratedValue]` where the declaration is invalid regardless of which commands exist in this or another assembly: * The method is not named `Handle`, including `Provide` and other method names. * The method is static, including a static `Handle`. * The method belongs to a `[ReadModel]` type, regardless of its name. * The method belongs to a controller: a type deriving from `Microsoft.AspNetCore.Mvc.ControllerBase`, or marked `[ApiController]` or `[Controller]`. An action named `Handle` is still reported. Inherited read-model and controller declarations are recognized. **Default severity: Error.** An instance `Handle()` that no command uses is **not reported**. Its attribute is inert: the runtime never reads it. This applies to public base types, sealed non-command types, and inaccessible types alike. The analyzer does not try to predict whether another assembly will define a command subclass. Only a model-bound command’s effective `Handle()` participates in generation. When a command uses an inherited handler, ARC0022, ARC0024, and ARC0025 still check that command’s generated parameters. ## Diagnostic example The examples use this identifier, the same declaration as in [generated values](/arc/backend/csharp/commands/generated-values/): ```csharp using System; using Cratis.Arc.Commands.ModelBound; using Cratis.Concepts; public record CartId(Guid Value) : ConceptAs(Value), IGeneratable { public static CartId New() => new(GenerateValue.Uuid()); } ``` Compile each alternative below together with it, one at a time; the first intentionally produces ARC0023. ```csharp [Command] public record AllocateCartId { public CartId Provide([GeneratedValue] CartId cartId) => cartId; public CartId Handle(CartId cartId) => cartId; } ``` ## Fix Move generation to the handler parameter: ```csharp [Command] public record AllocateCartId { public CartId Handle([GeneratedValue] CartId cartId) => cartId; } ``` For an ordinary service or read model dependency, remove `[GeneratedValue]` instead. Arc resolves those dependencies through the normal Provide and dependency injection path. # ARC0024: Generated value names must be non-empty and unique > Give each generated Handle parameter a distinct, non-empty, case-sensitive name. ## Rule Each generated parameter resolves its name from `[GeneratedValue("name")]`, or from the C# parameter name when no explicit name is supplied. Names must not be empty or whitespace, and must be unique within the command’s effective `Handle()` method using ordinal, case-sensitive comparison. This includes inherited handlers. The rule reports the later parameter and the effective command when names collide, even if the parameters have different types. **Default severity: Error.** ## Diagnostic example The examples use this identifier, the same declaration as in [generated values](/arc/backend/csharp/commands/generated-values/): ```csharp using System; using Cratis.Arc.Commands.ModelBound; using Cratis.Concepts; public record CartId(Guid Value) : ConceptAs(Value), IGeneratable { public static CartId New() => new(GenerateValue.Uuid()); } ``` Compile each alternative below together with it, one at a time; the first intentionally produces ARC0024. ```csharp [Command] public record AllocateCartIds { public (CartId First, CartId Second) Handle( [GeneratedValue] CartId first, [GeneratedValue("first")] CartId second) => (first, second); } ``` ## Fix Use distinct explicit names, or let each parameter use its own name: ```csharp [Command] public record AllocateCartIds { public (CartId First, CartId Second) Handle( [GeneratedValue("first")] CartId first, [GeneratedValue("second")] CartId second) => (first, second); } ``` Explicit names keep scenario pins stable across parameter renames. `"cart"` and `"Cart"` are distinct names; different commands can reuse either name. # ARC0025: Provide supplies a generated value type > Remove a redundant Provide value that cannot override a generated Handle parameter. ## Rule Arc resolves `[GeneratedValue]` parameters before matching values from `Provide()`. A compatible provided value never supplies that generated parameter. The analyzer matches the command’s effective `Handle()` and `Provide()` methods, including inherited handlers and public or non-public instance providers. A provider hidden by a more-derived provider is not used. A nullable struct provider returning `T?` can supply `T`: a non-null value is boxed as `T`, while a null value supplies nothing. It unwraps `Task`, `ValueTask`, and OneOf alternatives, and flattens one tuple level, matching runtime resolution. Nested tuple elements and tasks inside a tuple are not flattened or awaited. **Default severity: Warning.** ## Diagnostic example The examples use this identifier, the same declaration as in [generated values](/arc/backend/csharp/commands/generated-values/): ```csharp using System; using Cratis.Arc.Commands.ModelBound; using Cratis.Concepts; public record CartId(Guid Value) : ConceptAs(Value), IGeneratable { public static CartId New() => new(GenerateValue.Uuid()); } ``` Compile each alternative below together with it, one at a time; the first intentionally produces ARC0025. ```csharp [Command] public record AllocateCartId { public CartId Provide() => CartId.New(); public CartId Handle([GeneratedValue] CartId cartId) => cartId; } ``` ## Fix Remove the redundant provided value: ```csharp [Command] public record AllocateCartId { public CartId Handle([GeneratedValue] CartId cartId) => cartId; } ``` If `Provide()` should own the value instead, remove `[GeneratedValue]` from the parameter. Other unmarked `Handle()` parameters can still consume a provided value of the same type. The warning does not mean that every value of that type returned by `Provide()` is unused; review those parameters before removing it. # ARC0026 - Invalid explicit query declaration > Correct an invalid Query marker or ambiguous explicit query name. ## Correct the marked declaration `[Query]` expresses endpoint intent on a `[ReadModel]`. It does not make an invalid method eligible. A marked method must be ordinary, public, static, and nongeneric, with value parameters and a supported read-model return shape. Plain `Task`, unsupported return types, by-reference arguments or results, and a missing read-model owner are errors. Correct the declaration or remove the marker. `[Query]` never makes a non-public method reachable over HTTP; [ARC0029](/arc/backend/csharp/code-analysis/arc0029/) also warns about non-public query declarations. A parameter of the exact declaring read-model type (`T` or nullable `T?`) makes a method ineligible, regardless of its name or parameter position. `[Query]` cannot override that exclusion. Remove `[Query]` from a self-reduction/helper method; leave its current-state parameter intact. Validation applies even when `RequireQueryAttribute` is false. That default retains conventional discovery: marking one method does not hide siblings or widen return eligibility. ## Resolve a duplicate name In `RequireQueryAttribute = true` mode, two selected overloads with the same name cannot have distinct query identities. Rename one endpoint or remove its marker. An unmarked overload can remain as a helper. See [explicit endpoint selection and migration preview](/arc/backend/csharp/queries/model-bound/static-methods/#choose-endpoints-explicitly-when-a-model-has-helpers). # ARC0027: Generated value implements both generation contracts > Remove an unused plain generation contract when Arc always selects context-dependent generation. ## Rule A `[GeneratedValue]` parameter implements both `IGeneratable` and `IGeneratableInContext` for its own type. **Default severity: Warning.** Arc always selects `New(GenerationContext)`; it never calls the plain `New()` for that parameter. The diagnostic is on the parameter, including when the concept comes from a referenced assembly. ## Diagnostic example This concept declaration intentionally implements both contracts. Use it with a command’s `[GeneratedValue]` Handle parameter to produce ARC0027: ```csharp using Cratis.Concepts; public record OrderId(string Value) : ConceptAs(Value), IGeneratable, IGeneratableInContext { public static OrderId New() => new(GenerateValue.TypeId("order")); public static OrderId New(GenerationContext context) => new(GenerateValue.TypeId("order", context)); } ``` ## Fix Remove `IGeneratable` and the plain factory when they have no other callers. Keep `IGeneratableInContext` and its context factory. If callers outside Arc still need both forms, suppress the warning narrowly with that justification. See [generation context](/arc/backend/csharp/commands/generated-values/#generate-with-the-commands-time-and-tenant) for received-time and tenant mapping. This diagnostic has no automatic code fix. # ARC0028: Move inline generation to a generated parameter > Make inline concept generation pinnable by moving it to a generated Handle parameter. ## Rule Inside a model-bound command’s instance `Handle()`, Arc recognizes a concept’s `X.New()` or a `GenerateValue.*(...)` / `Guid.NewGuid()` result directly converted or wrapped into a self-generatable concept. **Default severity: Info.** Raw primitives, helpers outside Handle, and generation inside nested functions are not reported. ## Diagnostic example The examples use this identifier, the same declaration as in [generated values](/arc/backend/csharp/commands/generated-values/): ```csharp using System; using Cratis.Arc.Commands.ModelBound; using Cratis.Concepts; public record CartId(Guid Value) : ConceptAs(Value), IGeneratable { public static CartId New() => new(GenerateValue.Uuid()); } ``` Compile this command together with it: ```csharp [Command] public record AllocateCartId { public CartId Handle() => CartId.New(); } ``` ## Quick fix **Move to a \[GeneratedValue] parameter** adds a concept-typed parameter and replaces the whole generation expression with its name: ```csharp [Command] public record AllocateCartId { public CartId Handle([GeneratedValue] CartId cartId) => cartId; } ``` The fix derives a name from the local variable, argument, or concept and avoids existing names. It keeps required parameters before optional or `params` parameters. Choose an explicit attribute name afterward if scenario pins should survive a C# parameter rename. The fix is deliberately local; it does not update direct Handle callers or move generation inside loops, branches, nested functions, virtual/override methods, or interface implementations. Nonconstant strategy arguments also prevent the fix. These cases need a manual migration so you can preserve allocation timing and cardinality. No Fix All action is offered. Review the concept’s factory before accepting: the parameter uses that factory, not arbitrary strategy arguments from the old expression. Follow [migrating generated identifiers](/arc/upgrading/generated-values/) for scenario pins and context-dependent strategies. # ARC0029 - Non-public static method is not exposed as a query > Understand why a non-public read-model method has no query endpoint or TypeScript proxy. ## Why the warning appears Arc exposes only **public static** query methods on `[ReadModel]` types. Internal, private, protected, protected internal, and private protected methods are not endpoints and have no generated query proxy, even with `[Query]`. A public static method on an internal read-model type can still be a query. ARC0029 is a warning on non-public static methods that return the declaring read model or a supported query wrapper, or carry `[Query]`. It also applies in `RequireQueryAttribute = true` mode. Ordinary helpers returning unrelated values, such as `string` or plain `Task`, do not trigger it unless marked `[Query]`. Earlier versions allowed internal query methods. Removing that exposure prevents a non-public helper from becoming callable over HTTP because of its return shape. Direct C# calls are unchanged. ## Decide whether the method is an endpoint If this method is an intended query, make it public and review its authorization and request parameters before exposing it. Keep its `[Query]` marker when the read model requires explicit query selection. If it is a helper, keep it non-public. Remove any `[Query]` marker; a non-public marked method also causes [ARC0026](/arc/backend/csharp/code-analysis/arc0026/). You may suppress ARC0029 locally for an intentional query-shaped helper after confirming it should not be exposed. Suppressing the warning never exposes the method. See [static query method requirements](/arc/backend/csharp/queries/model-bound/static-methods/). # ARC0030 - Composite key requires accessor registration > Register complete composite-key accessors when protected-read key generation cannot cover a declared type. ## Register the uncovered key type The model-graph generator reports this informational diagnostic when a statically declared custom composite key cannot receive complete generated accessors and the compilation references Chronicle’s accessor registry. Partial types, external types or bases, and inaccessible or otherwise unsupported member shapes can prevent complete generation. Register every public readable non-indexed instance-property getter, including inherited getters, through `ProtectedReadModelKeyAccessors.Register()` before startup. Registration covers the exact runtime type, not its subclasses. See [register composite protected-key accessors](/arc/backend/csharp/chronicle/protected-key-accessors/) for the API and strict-mode configuration. This diagnostic does not fail the build: registration can exist elsewhere in the application. In strict mode, including runtimes without dynamic-code support, missing coverage throws `ProtectedReadModelKeyAccessorMissing`. Compatible JIT execution can instead use the reflection fallback with a warning. Neither this diagnostic nor a successful registration establishes whole-Arc NativeAOT safety. # ARC0031 - Command key properties are read through reflection > A command that generated code cannot name has its key properties read through reflection by read protection. ## Generate the key properties or accept reflection The model-graph generator reports this informational diagnostic when the compilation references Chronicle’s command key registry and a command, or a type validated by a `CommandValidator`, cannot be named from generated code. Partial types, types with a base type from another assembly, private or file-local types, generic definitions, and properties with obsolete, experimental, or trim-annotated getters or inaccessible types can prevent generation. For such a command, read protection reads the public properties it needs through reflection. Behavior is unchanged, and reflection is compatible with JIT execution. It is not trimming or NativeAOT safe. This diagnostic does not fail the build. To remove it, make the command a non-partial, accessible type declared in your project, or accept the reflection fallback. # MediatR, MVC, and Arc > If you already build .NET apps with MediatR and ASP.NET Core controllers, here's how those ideas map onto Arc. If you’ve built ASP.NET Core apps with controllers, DTOs, and MediatR, you already know most of the *concepts* in Arc. This page maps those familiar pieces onto Arc’s command/query model. ## The one-paragraph version In MVC, a controller action takes a request model, validates it, calls a handler, and returns a response. With MediatR, that often becomes an `IRequest` and an `IRequestHandler`. In Arc, the command is a record with a `Handle()` method on it; Arc maps that command to an HTTP endpoint and generates a typed client for your frontend. ## How the pieces map | You know (MediatR / MVC) | In Arc | | --- | --- | | `IRequest` + `IRequestHandler` | A `[Command]` record with `Handle()` defined **on the record** | | Controller action + routing attributes | Automatic — Arc maps the command/query to HTTP for you | | Request/response DTOs | The command record itself; the result is what `Handle()` returns | | FluentValidation / `ModelState` | A `CommandValidator` discovered by convention | | MediatR pipeline behaviors | The command pipeline and filters | | `INotification` / handlers | A follow-up command, domain service, or optional [Chronicle reactor](/arc/backend/csharp/chronicle/) when you are event-sourcing | | A query action returning a DTO from EF | A **query** method on a `[ReadModel]`, served directly over HTTP | | Application-specific `fetch`/HttpClient on the frontend | A **generated TypeScript proxy** | ## What stays the same * You still think in commands and queries — the CQRS split you already use with MediatR is first-class here. * You still write small, focused handlers and validators. * You still use dependency injection; declare collaborators as `Handle()` / `Provide()` method parameters, and constructor-inject them into validators. Command constructor parameters describe request data, not services. ## What changes * **Commands are endpoints.** The command record carries the request shape and the `Handle()` method; Arc maps it to HTTP. * **The handler sits with the intent.** `Handle()` lives on the command record, so the intent and its implementation sit together in one [vertical slice](/arc/vertical-slices/) instead of across `Commands/` and `Handlers/` folders. * **The frontend model is generated.** The proxy is generated from your C# types, so command/query shape changes are caught by TypeScript. * **The read side is explicit.** Instead of returning arbitrary DTOs from controllers, you name the read model and expose query methods on it. Those methods are generated into the frontend just like commands. ## A side-by-side A “register customer” feature in MediatR + MVC is often a request record, a handler, a validator, a controller action, and a frontend client call. In Arc, the same feature is modeled as a slice. This **MongoDB domain fragment** assumes `CustomerId` and `CompanyName` concepts plus the [standalone setup](/arc/backend/csharp/getting-started/), including `System.ComponentModel.DataAnnotations` for `Key` and a configured proxy build: ```csharp [Command] public record RegisterCustomer(CustomerId Id, CompanyName Name) { public Task Handle(IMongoCollection customers) => customers.InsertOneAsync(new Customer(Id, Name)); } [ReadModel] public record Customer([property: Key] CustomerId Id, CompanyName Name) { public static ISubject> AllCustomers(IMongoCollection customers) => customers.Observe(); } ``` Arc exposes both members over HTTP and generates the typed client. Your React component calls the generated command and query proxies; no controller, DTO mapper, or hand-written fetch layer sits between them. Declare inline work on model-bound commands The direct database call remains supported. Prefer [command operations](/arc/backend/csharp/commands/operations/) when a model-bound command can decide what inline work to perform before executing it. Returning an operation directly from an MVC action does not execute it; use a model-bound command through Arc’s pipeline. Operations support a flat sequential boundary, not nested command orchestration. ## Where to go next * Build your first command in the [getting started](/arc/backend/csharp/getting-started/) guide. * See the full command and query options under [Backend](/arc/backend/csharp/). * Need history, replay, or reactors later? The [Chronicle integration](/arc/backend/csharp/chronicle/) shows how Arc adds event sourcing as an optional write-side choice. # Commands > Change application state through standalone Arc commands, validation, and typed responses. Opening an account or adding a cart line should not require a handwritten HTTP client and repeated endpoint boilerplate. In Arc, a command carries the input for an operation and can handle itself. Arc supplies the endpoint, command result, and generated TypeScript proxy. Arc is a standalone CQRS framework. Your handler can call application services backed by any suitable storage; it does not have to return an event or use Chronicle. Return operations for inline side effects Prefer [command operations](/arc/backend/csharp/commands/operations/) when `Handle()` decides which external work to perform. The decision returns inspectable values; Arc executes them and manages optional compensation without application-written error handling. Direct service calls remain supported, and durable follow-up work still belongs in a reactor or appropriate workflow. ``` POST Client Arc endpoint Authorization then validation Provide and Handle Response processing CommandResult ``` ## Your first command This is a declaration fragment using the shared `CartLineId`, `Sku`, and `Quantity` concepts and fully implemented `ICartService` from the [model-bound command example](/arc/backend/csharp/commands/model-bound/#a-service-backed-command): ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record AddItemToCart(Sku Sku, Quantity Quantity) { public CartLineId Handle(ICartService carts) => carts.AddItem(Sku, Quantity); } ``` Arc discovers the public instance `Handle()` on the `[Command]` record. It resolves `ICartService` from the command scope, waits for the operation, and places the `CartLineId` in the response. Follow the linked example for the service implementation, registration, and an execution checkpoint. Choose the return shape that fits the operation: * **`void` or `Task`** — no response value. A task is awaited; this is not fire-and-forget execution. * **A value or `Task`** — an unhandled value becomes the typed response. * **An operation or `CommandOperations`** — declare one or many server-side actions, with optional compensation. * **A tuple** — combine one response with [command operations](/arc/backend/csharp/commands/operations/) and other values consumed by [response value handlers](/arc/backend/csharp/commands/response-value-handlers/). * **`Cratis.Monads.Result`** — process the active alternative. A recognized validation value becomes a rejection; arbitrary error types are not automatically failures. Use [provided data](/arc/scenarios/provide-data-to-a-command/) when fetching handler input separately makes the operation clearer. ## Two ways to define a command | Style | Choose it when | | --- | --- | | [Model-bound](/arc/backend/csharp/commands/model-bound/) | You want input and behavior together with minimal endpoint boilerplate. | | [Controller-based](/arc/backend/csharp/commands/controller-based/) | You need MVC action control or are integrating existing controllers. | The model-bound pipeline and MVC action filters are different execution paths. Do not assume extensions or authorization attributes for one automatically govern the other. ## Build on the basics | Next concern | Read | | --- | --- | | Validate input | [Validation](/arc/backend/csharp/commands/validation/) | | Protect commands | [Model-bound authorization](/arc/backend/csharp/commands/model-bound/authorization/) | | Check without invoking the handler | [Pre-flight validation](/arc/backend/csharp/commands/command-validation/) | | Execute from application code | [Command pipeline](/arc/backend/csharp/commands/command-pipeline/) | | Keep inline side effects out of the decision | [Command operations](/arc/backend/csharp/commands/operations/) · [Testing operations](/arc/backend/csharp/testing/command-operations/) | | Understand scalar and tuple responses | [Response value handlers](/arc/backend/csharp/commands/response-value-handlers/) · [Examples](/arc/backend/csharp/commands/response-examples/) | | Carry cross-cutting data | [Command context](/arc/backend/csharp/commands/command-context/) | | Extend execution | [Filters](/arc/backend/csharp/commands/command-filters/) · [Execution scopes](/arc/backend/csharp/commands/command-execution-scopes/) | ## Optional Chronicle integration If your application uses event sourcing, [Chronicle command integration](/arc/backend/csharp/chronicle/commands/) adds returned-event handling. Its [transactional command behavior](/arc/backend/csharp/commands/transactional-commands/) belongs to that integration, not standalone Arc. You do not need it for service-backed commands or `Guid` responses. ## The frontend follows the backend The [proxy generator](/arc/backend/csharp/proxy-generation/) generates the TypeScript command contract from the backend. After building the backend, use [commands in React](/arc/frontend/react/commands/) to submit it and inspect the result. Continue to [queries](/arc/backend/csharp/queries/) to read the data your commands change. # Command context > Access invocation metadata and understand when dependencies and responses become available. When several pipeline extensions need the same invocation metadata, carry it in `CommandContext` rather than adding transport details to the command’s input. The context is a record whose contents depend on the execution phase. ## Properties | Property | Meaning | | --- | --- | | `CorrelationId` | The invocation’s correlation identifier. | | `Type`, `Command` | The command type and instance. | | `Dependencies` | Resolved handler arguments, initially empty. | | `Values` | A case-insensitive `CommandContextValues` dictionary. | | `AllowedSeverity` | Optional threshold for the stages described in [severity filtering](/arc/backend/csharp/commands/validation-severity-filtering/). | | `Response` | Selected caller response, if any; not the raw handler return. | | `ServiceProvider` | The provider for this invocation’s service scope, when supplied. | | `CancellationToken` | The execution cancellation token. | | `ReceivedAt` | `DateTimeOffset` captured once when Arc receives this operation. | `ReceivedAt` is Arc receipt time, not network arrival or a timestamp before application middleware. Model-bound HTTP dispatch captures it before body/argument binding and authorization preparation; direct pipeline calls capture it on entry. A hub captures a new value for each subscribe operation, not for the connection. MVC contexts are established by action filters after MVC binding. The value stays fixed through authorization, validation, handler execution, and any Arc-owned replacement service scope. During a decorated transport dispatch, sibling public pipeline calls (such as `Validate` followed by `Execute`, or a query followed by a command) share the transport receipt. A pipeline call started from inside another pipeline entry captures a new receipt and restores the outer value afterward. The offer ends with the dispatch: work that starts a pipeline after the dispatch finishes captures a fresh receipt, even if its execution context was created during dispatch. Context values are application/extension data, not automatically trusted identity claims. Use `ICurrentPrincipalAccessor` for the [authorization principal](/arc/backend/csharp/commands/model-bound/authorization/). ## Command context values Implement `ICommandContextValuesProvider.Provide(object command)` to contribute values. Arc’s builder merges providers in discovery order; a later value with the same case-insensitive key overwrites the earlier one. Use namespaced keys you own. This complete provider adds tracing metadata without reading request-supplied identity: ```csharp using System; using System.Diagnostics; using Cratis.Arc.Commands; public class TraceContextValuesProvider : ICommandContextValuesProvider { public CommandContextValues Provide(object command) => new() { ["Example.ObservedAt"] = DateTimeOffset.UtcNow, ["Example.CommandType"] = command.GetType().FullName ?? command.GetType().Name, ["Example.TraceId"] = Activity.Current?.TraceId.ToString() ?? string.Empty }; } ``` Under normal Arc type discovery, the provider is included automatically. It runs during context construction for execution **and validation**, before command authorization filters. Keep it lightweight and avoid sensitive output or state changes; pre-flight validation cannot make a custom provider pure. Arc may also populate reserved context values such as a resolved command key. Do not overwrite values owned by the framework or another integration. ## Accessing command context Filters, response handlers, and execution scopes receive a context argument directly. Components that do not receive one can inject `ICommandContextAccessor` for the established ambient context. This complete command uses a provided tracing value and returns it as ordinary response data: ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; [Command] public record ReadInvocationTrace() { public string Handle(ICommandContextAccessor accessor) => accessor.Current.Values.TryGetValue("Example.TraceId", out var traceId) ? traceId.ToString() ?? string.Empty : string.Empty; } ``` The empty response means no trace identifier was supplied. This is a metadata demonstration, not a permission check or a business-state query. ## Phase availability During scope `Begin` and command filters, `Dependencies` is empty and `Response` is null. After filters, Arc resolves `Provide()` values and handler arguments and uses an updated context for handler invocation. During response processing it may create another context record with a selected response. Use the context **passed to a lifecycle callback** for its phase’s dependencies and response. The ambient accessor is established earlier; do not assume a retained accessor value is replaced whenever the pipeline creates a later record copy. `Values` is shared by those copies and remains mutable. A validator resolved from the operation’s service provider can inject `IOperationContextAccessor` (`Cratis.Arc`) and read its nullable `ReceivedAt`. It is null outside an Arc operation and in MVC-bound validators that run during model binding, before the command action filter starts the receipt. To test time-sensitive rules, register your own `TimeProvider` before or after `AddCratisArcCore()`; Arc uses `TryAddSingleton` for `TimeProvider.System`, so an app registration takes precedence regardless of order. Do not call `DateTimeOffset.UtcNow` to reconstruct a receipt timestamp in a validator. At scope `Complete`, a successfully processed simple response is available just as a tuple-selected response is. The result can still fail during scope completion. See [response object availability](/arc/backend/csharp/commands/response-value-handlers/#response-object-availability) before using a response inside an extension. MVC action filters establish some ambient command context separately, but controller actions do not pass through this model-bound lifecycle. Continue to [execution scopes](/arc/backend/csharp/commands/command-execution-scopes/) or [command filters](/arc/backend/csharp/commands/command-filters/) for extension examples. # Command execution scopes > Observe model-bound command execution before filters and after response processing. A pre-execution filter cannot report how a command finished. An execution scope brackets model-bound execution: begin before the filters, then complete with the outcome after the handler and response processing. Use it for timing or for a storage coordination mechanism you actually implement. Use operations for per-command side effects Prefer [command operations](/arc/backend/csharp/commands/operations/), rather than a custom scope and rollback stack, for work chosen by one command. Arc manages operation failure handling and optional compensation. Scopes remain the integration mechanism for cross-cutting lifetime and commit concerns. ## How it works Arc discovers `ICommandExecutionScope` implementations and resolves them from the command’s service scope. Once a handler and context are available, the pipeline: 1. Materializes the scope instances and calls synchronous `Begin(context)` on each. 2. Awaits `BeginAsync(context)` sequentially on scopes implementing `IAsyncCommandExecutionScope`, in the same order. 3. Runs filters, resolves provided data and handler arguments, awaits `Handle()`, and processes its response. 4. Calls asynchronous `Complete(context, result)` in reverse scope order, including when validation fails or execution throws. `Begin` is synchronous so ambient state it establishes can flow into execution. Each materialized scope completes once, but its `Begin` may never have run if an earlier scope threw. Missing-handler or context/scope-construction failures before scopes are available do not run this lifecycle. Pre-flight `Validate` does not run execution scopes. For asynchronous initialization, implement `IAsyncCommandExecutionScope` alongside the ordinary lifecycle. All synchronous begins finish first, so ambient state is established before asynchronous work starts. Context values are already built. Honor `context.CancellationToken`; cancellation or initialization failure skips filters and follows the same completion cleanup as other execution failures. Set ambient `AsyncLocal` state in synchronous `Begin`, not inside `BeginAsync`: changes inside an async method do not flow back to its caller. Pre-flight `Validate` skips both synchronous and asynchronous execution scopes. With no asynchronous scopes, the existing lifecycle is unchanged. Relative discovery order is unspecified; keep scopes independent. An exception in one `Complete` is merged into the result and does not prevent remaining scopes from completing. ``` Rejected Begin scopes Authorization and validation Provide and Handle Response handlers Complete scopes in reverse order Final result ``` ## Implementing a custom execution scope This complete scope records elapsed time through .NET logging. It requires a resolvable `ILogger` and a logging provider to observe output. It stores state in the per-command values dictionary, so shared scope instances do not share timers across commands. ```csharp using System; using System.Diagnostics; using System.Threading.Tasks; using Cratis.Arc.Commands; using Microsoft.Extensions.Logging; public class CommandTimingScope(ILogger logger) : ICommandOperationExecutionScope { const string TimerKey = "Example.CommandTimingScope.Timer"; static readonly Action _completed = LoggerMessage.Define(LogLevel.Information, new EventId(1, "CommandTimed"), "Command {CommandType} took {Milliseconds} ms; success at timing completion: {IsSuccess}"); public bool IsCommitParticipant => false; public CommandCommitDisposition GetCommitDisposition(CommandContext context) => CommandCommitDisposition.NoCommit; public void Begin(CommandContext context) => context.Values[TimerKey] = Stopwatch.StartNew(); public Task Complete(CommandContext context, CommandResult result) { if (context.Values.TryGetValue(TimerKey, out var value) && value is Stopwatch timer) { timer.Stop(); _completed(logger, context.Type.FullName ?? context.Type.Name, timer.Elapsed.TotalMilliseconds, result.IsSuccess, null); context.Values.Remove(TimerKey); } return Task.CompletedTask; } } ``` The timing scope implements `ICommandOperationExecutionScope`, declaring that it does not commit business changes. This makes its lifetime behavior explicit for operation-bearing commands while preserving ordinary execution-scope behavior. A scope that actually commits storage must report its real commitment facts instead; see the [supported operation scope profile](/arc/backend/csharp/commands/operations/reference/#supported-scope-profile). If `Begin` did not run, completion safely does nothing. This duration includes response processing and any scopes that complete before this one. Another scope can still change the outcome afterward, so the log deliberately describes success **at this completion callback**, not an immutable final verdict. ## Mutating the result — handle with care `Complete` receives a mutable `CommandResult`. Add failures when completing your concern fails; never erase earlier authorization, validation, or exception outcomes. Removing failures could report success after another scope already acted on failure. A response may already be present in the callback context. It is not proof that every scope will succeed. After completion, the pipeline clears the **result’s** response if execution is unsuccessful. See [response phase availability](/arc/backend/csharp/commands/response-value-handlers/#response-object-availability). ## Optional integrations Standalone Arc supplies this extension point, not an automatic database transaction. Your own scope must implement any begin/commit/rollback behavior it promises, including failure and nesting semantics. `Cratis.Arc.Chronicle` supplies a separate `TransactionalCommandScope`. Its event-store-specific guarantees are documented under [Chronicle transactional commands](/arc/backend/csharp/commands/transactional-commands/); they do not apply to arbitrary service writes. Execution scopes apply wherever the model-bound pipeline runs, including HTTP and direct [pipeline calls](/arc/backend/csharp/commands/command-pipeline/). They do not wrap MVC controller actions. Use [command filters](/arc/backend/csharp/commands/command-filters/) when you only need a pre-execution decision. # Command filters > Intercept model-bound commands, with authorization evaluated before ordinary filters. Use a command filter when a rule applies before multiple commands run. Filters belong to the **model-bound `ICommandPipeline`**, not the MVC action pipeline. For controller-based commands, use ASP.NET Core filters and authorization. ## How it works Arc discovers `ICommandFilter` implementations and resolves them from the command’s service scope. `OnExecution(CommandContext)` runs before `Provide()` and `Handle()`. 1. Filters implementing **`IAuthorizationCommandFilter`** run first. 2. Ordinary `ICommandFilter` implementations run afterward. 3. Within each group, filters retain discovery order; do not use that as a configurable priority mechanism. 4. Each result is merged. The chain stops at the first unsuccessful result; a throwing filter contributes a failure rather than discarding earlier verdicts. The pipeline applies [severity filtering](/arc/backend/csharp/commands/validation-severity-filtering/) after this chain. That page describes the current limitation when a warning stops the chain but is later removed. ## Implementing a custom command filter This complete filter logs the command type without logging its possibly sensitive payload. It requires normal Arc discovery and a resolvable `ILogger` with a configured provider if you want to observe messages. ```csharp using System; using System.Threading.Tasks; using Cratis.Arc.Commands; using Microsoft.Extensions.Logging; public class CommandLoggingFilter(ILogger logger) : ICommandFilter { static readonly Action _checking = LoggerMessage.Define( LogLevel.Information, new EventId(1, "CheckingCommand"), "Checking command {CommandType}"); public Task OnExecution(CommandContext context) { _checking(logger, context.Type.FullName ?? context.Type.Name, null); return Task.FromResult(CommandResult.Success(context.CorrelationId)); } } ``` This logs a pre-execution check, not successful completion. It also runs during pre-flight validation. For final outcomes use [execution scopes](/arc/backend/csharp/commands/command-execution-scopes/). ## Cross-cutting authorization by namespace For access control, implement **`IAuthorizationCommandFilter`**, not just `ICommandFilter`. Return `CommandResult.Unauthorized`, not a severity-overridable validation error. This complete filter requires an authenticated principal with the `Payments` role for commands in exactly `MyApp.Payments` or one of its child namespaces. It uses Arc’s transport-independent principal accessor, which also supports trusted server-side execution scopes. ```csharp using System; using System.Threading.Tasks; using Cratis.Arc.Authorization; using Cratis.Arc.Commands; public class PaymentsAuthorizationFilter(ICurrentPrincipalAccessor principals) : IAuthorizationCommandFilter { const string ProtectedNamespace = "MyApp.Payments"; public Task OnExecution(CommandContext context) { var commandNamespace = context.Type.Namespace; var protectedCommand = commandNamespace == ProtectedNamespace || (commandNamespace?.StartsWith(ProtectedNamespace + ".", StringComparison.Ordinal) ?? false); if (!protectedCommand) { return Task.FromResult(CommandResult.Success(context.CorrelationId)); } var principal = principals.Current; var authorized = principal?.Identity?.IsAuthenticated == true && principal.IsInRole("Payments"); return Task.FromResult(authorized ? CommandResult.Success(context.CorrelationId) : CommandResult.Unauthorized(context.CorrelationId, "Payments access is required.")); } } ``` Keep this class in a discovered assembly and configure authentication to establish the trusted principal. The namespace boundary check deliberately excludes names such as `MyApp.PaymentsPublic`. Renaming a protected command outside the namespace changes its protection, so test your command inventory as well as allowed and denied principals. This role gate does not implement record ownership; add an actual ownership decision if your operation needs one. ## Built-in filters | Filter in `Cratis.Arc.Commands.Filters` | Behavior | | --- | --- | | `AuthorizationFilter` | Implements `IAuthorizationCommandFilter`; evaluates Arc authentication/role requirements and returns an unauthorized verdict when denied. | | `DataAnnotationValidationFilter` | Validates command properties. Positional records require targets such as `[property: Required]`. | | `FluentValidationFilter` | Validates the command graph with discovered validators. | Use [command validation](/arc/backend/csharp/commands/validation/) for complete annotation and validator examples. For the exact supported attribute namespaces and the current policy/scheme limitation, read [model-bound authorization](/arc/backend/csharp/commands/model-bound/authorization/). ## Context availability At filter time `CommandContext.Command`, `Type`, `CorrelationId`, `Values`, `ServiceProvider`, and `CancellationToken` describe the current invocation. `Dependencies` is still empty and `Response` has not been selected. Resolve any required collaborator through constructor injection rather than expecting handler arguments to be available already. Keep filters focused, avoid exposing sensitive details in rejection messages, and test them through both execution and pre-flight validation. See [command context](/arc/backend/csharp/commands/command-context/) for later phases. # Command pipeline > Execute and await standalone Arc commands from application code. When a background job or application service needs the same command behavior as an HTTP caller, inject `ICommandPipeline`. It runs the model-bound authorization, validation, provisioning, handler, response handlers, command operations, and execution scopes without an HTTP round trip. Declare work instead of writing a recovery stack For immediate side effects chosen by a command, return [command operations](/arc/backend/csharp/commands/operations/). The pipeline awaits their execution and eligible compensation. Backend code and [CommandScenario](/arc/backend/csharp/testing/command-operations/) can inspect recovery observations without moving recovery handling to the frontend. Mapped HTTP endpoints also send body-binding failures through resource-free authorization before returning validation. This path creates no command context or execution scopes, and emits command/validation spans just like other refusals. See [input binding](/arc/backend/csharp/commands/input-binding/#authorization-before-reporting-input-errors) for named-policy limits; direct pipeline callers already supply a bound instance. ## Basic usage These complete caller types use `AddItemToCart`, `ICartService`, and the shared cart concepts from the [service-backed example](/arc/backend/csharp/commands/model-bound/#a-service-backed-command). Configure Arc and the example’s service registration before resolving them. ### Without a service provider ```csharp using System.Threading.Tasks; using Cratis.Arc.Commands; public class CartApplicationService(ICommandPipeline pipeline) { public Task> Add(Sku sku, Quantity quantity) => pipeline.Execute(new AddItemToCart(sku, quantity)); } ``` Each scope-free call creates and disposes its own DI scope. The returned task represents completed execution, including response handlers and scope completion. The caller must await it before using the result; returning the task from an asynchronous API, as above, preserves that contract. ### With a service provider ```csharp using System; using System.Threading.Tasks; using Cratis.Arc.Commands; public class ScopedCartApplicationService(ICommandPipeline pipeline, IServiceProvider services) { public Task> Add(Sku sku, Quantity quantity) => pipeline.Execute(new AddItemToCart(sku, quantity), services); } ``` Resolve this caller inside the existing scope you intend to share. Passing the root provider does not create a command scope. Scope-explicit calls use the supplied provider and leave its lifetime to the caller. ## Execute for a specific tenant An off-request worker has no HTTP tenant to inherit. Inject `ITenantScope` and begin a scope before calling the pipeline: ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Tenancy; public class TenantCartJob(ITenantScope tenants, ICommandPipeline pipeline) { public async Task> Add(TenantId tenant, Sku sku, Quantity quantity, CancellationToken stoppingToken) { using (tenants.Begin(tenant)) { return await pipeline.Execute(new AddItemToCart(sku, quantity), stoppingToken); } } } ``` `ITenantScope.Begin` selects a nonempty tenant for the current async flow. The explicit selection wins over the configured resolver, **including during an HTTP request** and when an authentication scheme selects a different principal. Nested scopes restore the outer tenant; disposal restores the previously resolved tenant. Disposal is idempotent. If an outer scope is disposed before an inner one, the inner tenant remains selected and the disposed outer tenant is skipped when the inner scope ends. The scope also works with `Validate`, queries, reads through `IReadModels`, and both typed and untyped execution, including cancellation-aware calls. Selecting a tenant does not grant authorization: enforce tenant access and establish a trusted principal separately when required. Tasks or timers started inside a scope capture its tenant even after the parent disposes it; do not reuse that captured context for unrelated work. Independent HTTP requests reset tenant selection when they enter Arc’s request pipeline, and observable emissions restore the subscriber’s tenant rather than inheriting the producer’s scope. On ASP.NET Core that reset runs in Arc’s startup filter, so it covers all application middleware, including middleware registered before `UseCratisArc()`, but not middleware added by another `IStartupFilter` registered before Arc’s; do not resolve tenant-scoped services in such middleware. `ITenantScope` requires Arc’s `TenantIdAccessor` as the effective `ITenantIdAccessor` registration. If you register a custom accessor (before or after Arc), resolving `ITenantScope` throws `ExplicitTenantScopeRequiresArcAccessor` rather than selecting a tenant that other services cannot see. **Create a new DI scope inside the tenant scope before resolving tenant-scoped services** such as Chronicle’s event store or a `DbContext`. A service already resolved in an existing DI scope remains bound to that scope’s original tenant, even if you change the current tenant. Scope-free pipeline `Execute` and `Validate` create their own DI scopes inside the tenant scope for you; if you pass an existing `IServiceProvider`, you are responsible for creating it after `Begin` and for disposing it. The same rule applies to other tenant-scoped reads. ## Cancellation HTTP command endpoints pass the request-aborted token. `Provide()` and `Handle()` may accept a `CancellationToken`, which Arc supplies directly. The following complete command waits asynchronously; it is a timing demonstration, not a background-work scheduler: ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands.ModelBound; [Command] public record WaitForInterval(int Milliseconds) { public Task Handle(CancellationToken cancellationToken) => Task.Delay(Milliseconds, cancellationToken); } ``` Caller fragments, with `Cratis.Arc.Commands` and `Cratis.Arc.Validation` imported: ```csharp var result = await pipeline.Execute(new WaitForInterval(100), cancellationToken); ``` ```csharp var result = await pipeline.Execute( new WaitForInterval(100), serviceProvider, allowedSeverity: ValidationResultSeverity.Warning, cancellationToken: cancellationToken); ``` Use these cancellation extension overloads with Arc’s cancellation-aware pipeline. A custom implementation of only `ICommandPipeline`, rather than `ICommandPipelineWithCancellation`, receives the compatibility call without a token. Awaiting the delayed command waits for its task; cancellation is handled through the pipeline’s result/error path, so inspect the result rather than assuming every cancellation throws to your caller. ## Command results | Property | Meaning | | --- | --- | | `IsSuccess` | Authorized, no remaining validation results, and no exceptions. | | `IsAuthorized` | The authorization verdict. | | `IsValid` | Whether `ValidationResults` is empty; this alone does not establish success. | | `HasExceptions` | Whether exception messages are present. | | `ValidationResults` | Individual Arc validation failures remaining after applicable filtering. | | `ExceptionMessages`, `ExceptionStackTrace` | Exception details; handle as potentially sensitive diagnostic information. | | `AuthorizationFailureReason` | A supplied reason for denial, when present. | | `CorrelationId` | Identifier for correlating this execution. | | `Recovery` | Optional backend-only command operation recovery summary; excluded from HTTP JSON. | | `OperationOutcomes` | Backend-only observations of started operation invocations; excluded from HTTP JSON. | ## Typed command results Caller fragment using the cart example: ```csharp var result = await pipeline.Execute(new AddItemToCart("BOOK-1", 2)); if (result.IsSuccess) { var lineId = result.Response; Console.WriteLine(lineId); } ``` `Execute` returns `CommandResult` with a `Response` property. Request the response type, not the raw tuple or `Result` wrapper: [response processing](/arc/backend/csharp/commands/response-value-handlers/) determines the value exposed to callers. A response assignable to `TResult`, including an interface or base type, is supported. A genuinely incompatible requested type causes an `InvalidCastException` from the typed overload. Without a response, the typed overload supplies **`default(TResult)`**: null for a reference type, `Guid.Empty` for `Guid`, and zero for `int`. A `void` or `Task` handler has no response, but still runs to completion before the result is returned. Use non-generic `Execute` when you do not need a typed value. Scope completion can fail after the handler produced a value. After scopes complete, failed execution clears an already selected response to the default of its **concrete response type**. When requesting that same type, this yields `default(TResult)`, including `Guid.Empty`. Currently, adapting a cleared value-type response to `object` or a compatible interface can preserve its boxed default instead of null. For example, `Execute` can return a boxed `Guid.Empty` after scope completion fails. Always check `IsSuccess` before using the response; neither null nor a default-valued response is itself an authorization or success verdict. ## Exception handling The pipeline catches handler, filter, response-handler, and scope-completion exceptions and represents failures in `CommandResult`. Exceptions implementing `IValidationFailure` can be translated into validation outcomes; ordinary exceptions produce exception details. A wrong requested generic response type is a separate caller error, described above. Arc does not undo arbitrary direct application-service writes on failure. Prefer [command operations](/arc/backend/csharp/commands/operations/) for inline work you want the framework to execute and, when safe, compensate. Their commit-aware recovery does not imply atomic rollback, automatic retries, or crash recovery. Specialized persistence coordination still belongs in a deliberately implemented [execution scope](/arc/backend/csharp/commands/command-execution-scopes/). The [Chronicle transaction integration](/arc/backend/csharp/commands/transactional-commands/) is optional and has its own boundaries. ## Validation without execution Caller fragment: ```csharp var command = new AddItemToCart("BOOK-1", 2); var check = await pipeline.Validate(command); if (check.IsSuccess) { var execution = await pipeline.Execute(command); Console.WriteLine(execution.IsSuccess); } ``` `Validate` also has a scope-explicit overload and [severity thresholds](/arc/backend/csharp/commands/validation-severity-filtering/). It skips `Provide()`, `Handle()`, response processing, and execution scopes, but still runs context providers and command filters. It is not a guarantee that collaborators are side-effect free or that the later execution will succeed. See [pre-flight validation](/arc/backend/csharp/commands/command-validation/). ## Commands without a request Commands triggered by a Chronicle occurrence — a schedule, startup, shutdown or clock automation — are not run through `ICommandPipeline` directly. Use `IOccurrenceCommandExecutor` from `Cratis.Arc.Chronicle`, which keys the execution by occurrence and effect name and makes it retry-safe. See [Commands for occurrences](/arc/backend/csharp/chronicle/commands/occurrence-commands/). ## Context and authentication The current correlation context is used when available. Authorization uses the HTTP request principal when a request exists; otherwise it uses an explicitly established server-side principal. For trusted background execution, see [server-side authorization scopes](/arc/backend/csharp/commands/model-bound/authorization/#executing-commands-from-server-side-code). Do not assume a background command is automatically authorized or that a fresh DI scope creates a tenant or user identity. # Pre-flight command validation > Check authorization and input without invoking a command's Provide or Handle methods. A form can ask whether a command is currently acceptable before attempting the change. Arc’s `Validate` operation returns a `CommandResult` from pre-flight checks, without running the command handler. It is early feedback, not a reservation or a guarantee that later execution will succeed. ## Backend support For model-bound commands, inject `ICommandPipeline` and call `Validate`. Both a scope-free form and a form accepting an existing `IServiceProvider` are available, with optional severity and cancellation overloads. This complete caller uses the [service-backed `AddItemToCart`](/arc/backend/csharp/commands/model-bound/#a-service-backed-command) definition and shared cart concepts. Configure its validators and authorization requirements separately: ```csharp using System.Threading.Tasks; using Cratis.Arc.Commands; public class CartPreflight(ICommandPipeline pipeline) { public Task Check(Sku sku, Quantity quantity) => pipeline.Validate(new AddItemToCart(sku, quantity)); } ``` Await the returned task and inspect `IsSuccess` for an overall verdict. `IsValid` alone ignores authorization and exception failures. With no validators, pre-flight does not discover arbitrary checks inside the cart service. ## What runs The model-bound validation path finds the handler, builds context values, establishes a context, and runs command filters. Authorization filters run before ordinary filters, with short-circuiting on unsuccessful results. Applicable [severity filtering](/arc/backend/csharp/commands/validation-severity-filtering/) then determines the remaining validation results. It skips: * `Provide()` and handler argument resolution; * `Handle()`; * response value handlers; * execution scopes’ `Begin` and `Complete` callbacks. Validators can still resolve dependencies and read services. Context providers, custom filters, validators, and their constructors are executable application code: Arc cannot guarantee that they are side-effect free or reveal no sensitive information. Design them for repeated pre-flight calls. ## Model-bound endpoints Arc adds `/validate` alongside a model-bound command’s execute route. For example, **if the mapped execute route is** `/api/carts/add-item-to-cart`, validation is `POST /api/carts/add-item-to-cart/validate` with the same command payload. The base route depends on namespace/path configuration; it is not inferred from this example URL. Successfully bound bodies use model-bound command filters on both endpoints. If binding fails, both endpoints check authentication, roles, and explicitly resource-free policies before returning `malformedRequest` with the failing member when available. Resource-dependent policies and authorization command filters need an instance and do not run on that path; see [command input binding](/arc/backend/csharp/commands/input-binding/). Validation does not run business decisions that exist only in `Provide()`, `Handle()`, or a service invoked by them. ## Controller-based commands With Arc’s MVC integration, `[HttpPost]` actions with attribute route selectors receive corresponding `/validate` selectors unless `[AspNetResult]` opts the action or controller out of command handling. Use a `[FromBody]` parameter for the command payload, as in the complete [controller example](/arc/backend/csharp/commands/controller-based/). `CommandValidationRouteConvention` adds the selector at startup. Requests still go through ASP.NET Core authorization, binding, and the action-filter pipeline; Arc’s `CommandActionFilter` recognizes the `/validate` path and skips action execution. These are **MVC filters**, not the model-bound `ICommandFilter` chain. Other application middleware and filters may still run. For the shown controller route: * Execute: `POST /api/carts/add` * Validate: `POST /api/carts/add/validate` If a route is missing, confirm that Arc MVC integration is configured and that the action is recognized as a POST command with an attribute route. Do not assume every arbitrary MVC action receives validation endpoints. ## Security and concurrency Caution Skipping a handler is not a guarantee of no data exposure. Validation messages and custom pre-flight collaborators can reveal sensitive information. Protect validation and execution entry points, and review their output under denied principals. Use [authorization verdicts](/arc/backend/csharp/commands/command-filters/#cross-cutting-authorization-by-namespace), not overridable validation errors, for access control. Model-bound authorization failures and MVC authorization responses belong to their respective pipelines; do not assume identical HTTP challenge behavior. Always inspect the result of the eventual `Execute` call too. State may change between requests, handler-only checks may reject the operation, or a service or execution scope may fail. Enforce concurrency-sensitive invariants in the operation that performs the change. ## Frontend usage Use [core command validation](/arc/frontend/core/commands/validation/) or [React command validation](/arc/frontend/react/commands/validation/) for pre-flight feedback. For warning confirmation, use an explicit warning-blocking first threshold as described in [severity filtering](/arc/backend/csharp/commands/validation-severity-filtering/#confirm-a-warning). # Controller-based commands > Use Arc command result wrapping with ASP.NET Core MVC actions. When an existing HTTP API needs MVC action control, a controller action can represent the command. This is a different execution path from a model-bound `[Command]` record: ASP.NET Core handles routing, binding, authorization, and action invocation. ## Define the action This complete controller declaration reuses the shared cart concepts and fully implemented `ICartService` from the [service-backed example](/arc/backend/csharp/commands/model-bound/#a-service-backed-command). Configure Arc’s MVC integration and register that service in your host. `AddCartLine` is a body DTO, not a model-bound command. ```csharp using Microsoft.AspNetCore.Mvc; public record AddCartLine(Sku Sku, Quantity Quantity); [Route("api/carts")] public class CartsController(ICartService carts) : ControllerBase { [HttpPost("add")] public CartLineId AddItemToCart([FromBody] AddCartLine command) => carts.AddItem(command.Sku, command.Quantity); } ``` Posting valid input to `/api/carts/add` calls the service and returns its `CartLineId` in Arc’s command-result wrapper. The example’s store is in-memory, not durable or multi-user. Add real MVC validation and authorization before using this pattern for protected data. The [proxy generator](/arc/backend/csharp/proxy-generation/) uses the action method name for the generated TypeScript command. A returned task is awaited by MVC; an action returning `Task` must return actual asynchronous work, not an empty method body. ## Body-binding failures MVC authorization runs before body binding. Arc converts formatter ModelState paths such as `$.Items[2].From` to validation members such as `items[2].from`, preserving MVC’s messages. `[property: ExplicitOffset]` also works on controller body properties; see [command input binding](/arc/backend/csharp/commands/input-binding/) for supported date-time forms and normalization. `[ApiController]` automatic ProblemDetails responses can short-circuit before Arc’s action filter and retain ASP.NET’s response shape. This behavior is unchanged; do not assume every binding error uses the command-result envelope. ## Bypassing command result wrappers By default Arc wraps controller POST results in a `CommandResult`. Use Arc’s `[AspNetResult]` when you intentionally need ASP.NET’s result behavior instead. See [without wrappers](/arc/backend/csharp/asp-net-core/without-wrappers/) for its namespace and usage. Do not assume model-bound tuple response handlers, command filters, or execution scopes also process controller results. Return the actual response DTO or value from an MVC action and use MVC’s own extension points. Operation execution requires the command pipeline MVC action return values do not trigger [command operations](/arc/backend/csharp/commands/operations/). Keep returning the action’s actual response. To use operation execution and compensation, explicitly execute a model-bound command through `ICommandPipeline` and handle its result. ## Automatic validation endpoints Arc’s MVC convention adds a validation selector for the `[HttpPost]` action above. Actions or controllers marked `[AspNetResult]` are excluded from this convention: * Execute: `POST /api/carts/add` * Validate: `POST /api/carts/add/validate` The validation route accepts the same body and runs through ASP.NET Core binding and filters, but Arc skips the action itself. It therefore does not call `ICartService.AddItem`. Preconditions checked only inside that service are not pre-flight validation rules. See [pre-flight command validation](/arc/backend/csharp/commands/command-validation/#controller-based-commands) for route prerequisites, side-effect limits, and differences from model-bound validation. Use Microsoft ASP.NET Core authorization attributes on controllers/actions; [Arc model-bound authorization](/arc/backend/csharp/commands/model-bound/authorization/) documents its separate attribute contract. # Generated values > Give command handlers server-owned identifiers, codes and secrets without adding them to the request contract. A shop registers a customer. The new customer needs an identity before the first event can be recorded on it, and that identity has to come from somewhere. If the handler calls `CustomerId.New()` itself, a spec cannot choose the value. If you add a `CustomerId` property to the command, every client can choose it, and the identity stops being something only the server decides. A `[GeneratedValue]` Handle parameter is the way out. Arc creates the value before it calls `Handle()`, validates it, captures it for specs and keeps it out of the request. This page uses the shop from the [Generated values tutorial](/generated-values/); the code is from the [Generated Values Shop sample](https://github.com/Cratis/Samples/tree/main/GeneratedValues/Shop). The strategies themselves (UUID, ULID, TypeID, NanoID, bytes) are explained in [Generating values](/fundamentals/csharp/generating-values/) in Fundamentals; this page covers what Arc does with them. ## Declare a server-owned identifier The shop’s `CustomerId` is `cus_` followed by a lowercase ULID. The concept owns the strategy by implementing `IGeneratable` from Cratis.Fundamentals 7.23.0 or later, and its static `New()` method is the strategy: Shop/Common/CustomerId.cs ```csharp using Cratis.Chronicle.Events; using Cratis.Concepts; namespace Shop.Common; public record CustomerId(string Value) : EventSourceId(Value), IGeneratable { public static implicit operator CustomerId(string value) => new(value); public static CustomerId New() => new($"cus_{GenerateValue.Ulid().ToLowerInvariant()}"); } ``` The command then asks for it as a Handle parameter. The request carries only the customer’s name: Shop/Customers/Registration/RegisterCustomer.cs ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Concepts; using Shop.Common; namespace Shop.Customers.Registration; public record CustomerName(string Value) : ConceptAs(Value) { public static implicit operator CustomerName(string value) => new(value); } [Command] public record RegisterCustomer(CustomerName Name) { public (CustomerId, EventForEventSourceId) Handle([GeneratedValue("customerId")] CustomerId customerId) => (customerId, new EventForEventSourceId(customerId, new CustomerRegistered(Name))); } [EventType] public record CustomerRegistered(CustomerName Name); ``` Arc calls `CustomerId.New()` before invoking `Handle()`. The `customerId` parameter is not a command property, so it is not in the request body and not in the generated TypeScript proxy: the proxy for `RegisterCustomer` has a `name` property and nothing else. The handler returns the new identifier as the command’s response, so the caller learns it, and appends `CustomerRegistered` to the same identifier. This example uses Arc’s Chronicle integration for `EventForEventSourceId`. Generation does not depend on it: a handler without Chronicle can return the generated value alone. ### A plain Guid works too An identifier that implements `IGeneratable` over a `Guid` follows the same shape. These declarations allocate an identifier; they do not persist a cart or append an event, and the [migration guide](/arc/upgrading/generated-values/) reuses them: Shop/Carts/Starting/CartId.cs and StartCart.cs ```csharp using System; using Cratis.Arc.Commands.ModelBound; using Cratis.Concepts; public record CartId(Guid Value) : ConceptAs(Value), IGeneratable { public static CartId New() => new(GenerateValue.Uuid()); } [Command] public record AllocateCartId { public CartId Handle([GeneratedValue("cart")] CartId cartId) => cartId; } ``` Only non-nullable types that implement `IGeneratable` or `IGeneratableInContext` for themselves are valid. A raw `Guid`, a nullable concept, duplicate names, or a generated parameter on `Provide()` fails at handler construction. ## Generate with the command’s time and tenant An order identifier should sort by when the command arrived. If `OrderId.New()` read the clock itself, an order id, a log id and a receipt id created by one command would each carry a different creation time, and a spec could not fix any of them. Implement `IGeneratableInContext` from Cratis.Fundamentals 7.24.0 or later and Arc hands the strategy the command’s own context: Shop/Orders/Placing/PlaceOrder.cs ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Concepts; using Shop.Common; namespace Shop.Orders.Placing; public record OrderId(string Value) : EventSourceId(Value), IGeneratableInContext { public static OrderId New(GenerationContext context) => new(GenerateValue.TypeId("order", context)); } [Command] public record PlaceOrder(CustomerId CustomerId) { public (OrderId, EventForEventSourceId) Handle([GeneratedValue("orderId")] OrderId orderId) => (orderId, new EventForEventSourceId(orderId, new OrderPlaced(CustomerId))); } [EventType] public record OrderPlaced(CustomerId CustomerId); ``` Arc passes `CommandContext.ReceivedAt` as `GenerationContext.Now`, so every generated value uses the command’s captured received time instead of a second clock read. It also maps `ITenantIdAccessor.Current` to `GenerationContext.Tenant`: `TenantId.NotSet` and `TenantId.Default` become `GenerationContext.DefaultTenant` (the zero-GUID string), and named tenants use their `Value`. The shop’s `OrderId` takes its time from the command and has **no tenant in the value**. A TypeID has no tenant slot, and `GenerateValue.TypeId("order", context)` reads only `context.Now`. When a value really must carry the tenant, declare a separate concept, such as a `TenantOrderId` built as `$"{context.Tenant}-{GenerateValue.Ulid(context)}"`. Namespaces already isolate tenants in most applications, so this is rarely needed. See [Context-dependent values](/fundamentals/csharp/generating-values/context/) for the contract. Three rules to keep in mind: * If a type implements both interfaces, **Arc always calls the context form**. Its plain `New()` is never selected by Arc, and [ARC0027](/arc/backend/csharp/code-analysis/arc0027/) warns about it. * A default `ReceivedAt` throws `MissingGenerationTime`, even when a custom strategy ignores time. A manually constructed command context must supply its received time; Arc never substitutes `UtcNow`. * Scenario pins bypass generation, but still pass through validation. ## Keep a secret out of the causation chain The shop’s `ApiKey` is 32 random bytes. The secrets guidance in [Secrets and tokens](/fundamentals/csharp/generating-values/secrets-and-tokens/) covers why `Bytes(32)` is the strategy; the Arc part is what happens to the value afterwards. Shop/Customers/ApiKeys/ApiKey.cs ```csharp using Cratis.Arc.Chronicle.Commands; using Cratis.Concepts; namespace Shop.Customers.ApiKeys; [NotAudited] public record ApiKey(string Value) : ConceptAs(Value), IGeneratable { public static ApiKey New() => new(GenerateValue.Bytes(32, ValueEncoding.Base64Url)); } ``` Chronicle records a command’s property values on the causation of every event it appends. `[NotAudited]` on the concept leaves any command property of that type out of the causation chain, and that is **all** it does. It does not protect event payloads, command responses or logs. `IssueApiKey` generates a key and returns it once; the event it appends goes to the customer, not to the key: Shop/Customers/ApiKeys/IssueApiKey.cs ```csharp [Command] public record IssueApiKey(CustomerId CustomerId) { public (ApiKey, EventForEventSourceId) Handle([GeneratedValue("apiKey")] ApiKey apiKey) => (apiKey, new EventForEventSourceId(CustomerId, new ApiKeyIssued())); } [EventType] public record ApiKeyIssued; ``` The key is the response, so the caller sees it exactly once, and `ApiKeyIssued` carries no key. The `ApiKey` is never an event source id: the event is appended to the `CustomerId`. Keeping the key out of the event payload and out of logs is your code’s job; use Chronicle compliance (`[Encrypted]` and friends) for data that must be stored. `[NotAudited]` earns its place on commands that receive the key as input. The shop’s `RevokeApiKey` takes an `ApiKey` property, and a spec confirms its value does not appear on the causation of `ApiKeyRevoked`. A generated Handle parameter is not a command property, so it never reaches the causation chain in the first place. ## Choose a strategy Match the value to its job, then read the linked page before shortening anything: | Value | Strategy | Why | | --- | --- | --- | | `CustomerId`, `CartId` | `Ulid()` with a prefix, or `Uuid()` | Identifier; plenty of random bits | | `OrderId` | `TypeId("order", context)` | Names its type, sorts by the command’s time | | `InviteCode` | `NanoId(10, "23456789ABCDEFGHJKLMNPQRSTUVWXYZ")` | People read it aloud; only 50 random bits, so it is a generated property, never an identifier; back it with a uniqueness check where collisions matter | | `ApiKey` | `Bytes(32)` | Secret, 256 bits | The decision flow, collision tables and entropy guidance are in [Choosing a strategy](/fundamentals/csharp/generating-values/choosing-a-strategy/). ## Names and resolution `[GeneratedValue]` uses the C# parameter name. `[GeneratedValue("cart")]` supplies an explicit name that stays stable if you rename the parameter. Names are case-sensitive, non-empty and unique within the command. Specs pin values by this name. Arc generates values only after `Provide()` control results allow the command to continue. A value returned by `Provide()`, or registered in dependency injection, never overrides a generated parameter. Unmarked parameters keep their normal Provide and dependency injection behavior. The Arc source generator registers a typed factory for each accessible generatable type. Commands compiled without it can use a cached reflection fallback under the JIT. When dynamic code is unavailable, a missing factory fails at handler construction with `MissingGeneratedValueFactory`, naming the command and parameter. This factory path alone does not make the whole application NativeAOT-compatible. ## Validation and captures Arc resolves the concept’s discoverable validator from the command’s scoped provider and runs it asynchronously with the command’s cancellation token. A failing rule of any severity (Error, Warning or Information) rejects the generated or fixture-pinned value with `GeneratedValueRejected`. Warnings and information are not advisory for generated values: the server chose the value, so breaking a concept rule is a server defect, matching the Screenplay reference. Because it is not invalid request input, the command result contains an exception rather than a 400 validation failure. `CommandContext.GeneratedValues` exposes validated captures in parameter resolution order. Each entry has a zero-based `Order`, a `Name`, its declared `Type`, its `Value`, and the immutable `Freshness` policy. `Get("customerId")` retrieves the latest matching capture and fails if none exists. Context copies retain the same captures. Do not log or trace generated values Validator failure text can interpolate the value. `GeneratedValueRejected.Failures` keeps that text apart from the safe exception message, and rejected values are not captured. Keep both out of logs, and treat captures of secrets like the secret. ## When not to use it `[GeneratedValue]` is for new values that only the server should decide. It is the wrong tool when: * **The client chooses the identifier.** A customer to update, an order to cancel and any reference the caller supplies belong on command properties. Marking them generated would discard what the caller sent. * **You need idempotency.** Every execution generates a new value, so retrying a command creates a second customer. If a retry must be safe, accept a client-supplied idempotency key and check it. * **You need gapless or human-sequenced numbers.** Random and time-ordered values are unique in practice, not consecutive. An invoice number with no gaps needs a counter, not a strategy. * **The value is an authorization decision.** A generated value does not exist during authorization or validation, so a rule cannot read it. A short code such as `InviteCode` is not proof of identity either: pair it with an expiry and a check. * **You need a service or a read model.** Inject those as ordinary Handle dependencies. Generation also does not prove a value is absent from your storage. Time-ordered values, UUIDv7 included, reveal their creation time and are not monotonic within a millisecond. ## Guard a generated append target With Arc’s Chronicle integration, `GeneratedValueFreshness.Guarded` is the default. Returned events and aggregate mutations targeting a generated identifier require the whole event source to be empty, regardless of stream or event type. Occupancy produces `ConcurrencyViolation` with `ReasonDetail = "GeneratedIdentifierCollision"`; Arc does not rerun `Handle()`. `[GeneratedValue(Freshness = GeneratedValueFreshness.Unguarded)]` opts out while keeping generation and validation. Values used only in a response or payload add no scope. Direct user calls to unit-of-work append APIs inside `Handle()` are not automatically guarded. See [Fresh identifiers](/arc/backend/csharp/chronicle/commands/fresh-identifiers/) for supported paths, explicit-scope merging, version requirements and the direct-write workaround. ## Analyzers Arc checks generated declarations in the IDE and at compile time: [ARC0022](/arc/backend/csharp/code-analysis/arc0022/) rejects invalid or nullable types, [ARC0023](/arc/backend/csharp/code-analysis/arc0023/) rejects attributes outside command Handle parameters, [ARC0024](/arc/backend/csharp/code-analysis/arc0024/) rejects empty or duplicate names, and [ARC0025](/arc/backend/csharp/code-analysis/arc0025/) warns when `Provide()` supplies a generated type. [ARC0027](/arc/backend/csharp/code-analysis/arc0027/) warns about dual generation interfaces, and [ARC0028](/arc/backend/csharp/code-analysis/arc0028/) offers migration from inline generation to a parameter. Startup validation remains in place for commands compiled without the analyzers. ## Custom factories in a generated model Arc executes your concept’s generation contract at runtime; Screenplay recovery recognizes only the [documented factory forms](/arc/backend/csharp/generating-a-screenplay/#recovering-generation-strategies). A custom helper or external dependency can be valid Arc code without a recoverable Screenplay strategy. Self-contained String bodies, including multi-statement factories using `context.Now`, tenant formatting and `GenerateValue.*(context)`, recover as inline `generate rule` bodies in ESM v21. The Cratis C# target binds ValueGeneration to Fundamentals’ `Cratis.Concepts.GenerationContext`; passing that context to a Fundamentals API is target-bound, not a promise of portability to other providers. Rule bodies never become generated identifiers because their entropy is unknown. Literal `GenerateValue.Pattern` calls and signed `GenerateValue.Integer` ranges also recover in v21; an `int` factory needs one in-range cast, and unsigned backings are refused. `SP0073` names that limitation; it does not change your runtime factory or make the value caller-supplied. The generated model keeps independent parameters and behavior. It withholds a whole event production when a required payload member or explicit destination depends on the opaque value; an optional payload dependency removes only that mapping. Dependent responses and operations stay in code too. When no event production remains, the model keeps a handler file reference, which is not executable Screenplay behavior. Enabling authoring-only constructs or raising the executable version cap does not recover an unknown factory. Keep the original handler and use the diagnostics to identify what the partial model cannot run. ## Validated generation in a Screenplay model A concept validator does not by itself prevent recovery. An empty validator keeps the existing generation contract; a String-backed concept with completely recoverable rules can carry both `generate` and `validate` in ESM v20. Screenplay checks that the strategy and rules agree. Proven rules are admitted; non-opaque unproven rules stay in the model with Information `SP0078` (`PLAY0689`). Refuted rules withhold generation and dependent claims with `SP0073` and the `PLAY0688` witness, while preserving validation. Unreadable, conditional or opaque validation stays outside executable generation. UUID validator rules such as Guid `NotEmpty` also remain outside Screenplay’s declarative algebra: recovery preserves the original validator through a source attachment rather than pretending UUID is text. This does not change Arc’s runtime validation behavior. A cap below ESM v20 withholds validated generation, not its rules, in both output modes. ESM v21 additionally recovers unconditional String `Must(v => GenerateValue.HasValidChecksum(v, Checksum.Luhn))` rules, with `Mod11` and `Mod97` supported too. `None` and conditional checksum predicates are not recovered. Below v21, generation and its dependents are withheld while the checksum validator remains source-backed; rules are never stripped to fit an older cap. See [Validated generation](/arc/backend/csharp/generating-a-screenplay/#validated-generation-and-esm-v20) for the admission and fallback details. ## Next steps * [Pin generated values in command specs](/arc/backend/csharp/testing/generated-values/) so a spec can choose the value or assert its time part. * [Migrate generated identifiers](/arc/upgrading/generated-values/) when handlers still call `New()` inside `Handle()`. * [Generating a Screenplay](/arc/backend/csharp/generating-a-screenplay/#recovering-generation-strategies) to see which strategies Arc recovers into a model. * [Generated values, from Screenplay to running app](/generated-values/) for the whole path across products. * Inspect captures through the [command context](/arc/backend/csharp/commands/command-context/). # Command input binding > Understand malformed command input, authorization precedence, and explicit-offset date-time binding. A malformed date or a string sent for a number is not a failed business rule: Arc could not construct the command. Model-bound execute and `/validate` endpoints report this as a validation result with `reason: "malformedRequest"`, after the authorization checks that can run without a command instance. ## Authorization before reporting input errors Arc reads the body first, but does not report a binding failure until it checks the command’s effective authentication, roles, scheme selection, anonymous/guest handling, and fallback requirements. A denied caller gets **403 Forbidden**, not an input error. Host configuration failures remain failures rather than becoming validation results. For named ASP.NET Core policies, Arc evaluates the policy with a **null resource** only when every requirement in every resolved policy is resource-free: * `DenyAnonymousAuthorizationRequirement`, `RolesAuthorizationRequirement`, `ClaimsAuthorizationRequirement`, and `NameAuthorizationRequirement` are resource-free. * Custom requirements opt in by implementing `Cratis.Arc.Authorization.IResourceFreeRequirement` from `Cratis.Arc`. * `AssertionRequirement` is not resource-free. An assertion can read the resource even when a particular callback happens not to. Implement the marker only when all handlers for that requirement read the user, never the resource, directly or indirectly. A claim-to-command-property comparison must **not** implement it. The marker is a promise made by your application; Arc cannot inspect handler code to prove it. An application-registered extra handler for a built-in requirement (for example `RolesAuthorizationRequirement`) sees a `null` resource on this path; if it requires a resource, at worst that caller gets 400 instead of 403. If any resolved policy has a resource-dependent requirement, Arc does not evaluate the policy set for an unbindable body. A caller meeting authentication and role requirements then gets **400 Validation**, even if a policy would deny a successfully bound command. Native Core `IAuthorizationPolicy` implementations also remain resource-dependent: they have no ASP.NET requirement set to classify. `IAuthorizationCommandFilter` implementations never run without a command instance. No command context values, execution scopes, `Provide()`, or `Handle()` run on this path. Successfully bound commands retain the full authorization pipeline, including resource policies and authorization command filters. ## Members and messages The validation result’s `members` identifies the failing JSON path when the deserializer supplies one: | Deserializer path | Validation member | | --- | --- | | `$.from` | `from` | | `$.Items[2].When` | `items[2].when` | | `$` or no path | No member | Arc camel-cases each dotted segment and retains array indices. Root failures and concept converters that throw a non-JSON parsing exception may have no member. Custom JSON property names also pass through this casing normalization. Ordinary binding failures use **“The request body could not be read or is not valid for this command.”** Arc does not echo arbitrary converter messages, parser details, or target type names. Arc-owned explicit-offset refusals use **“DateTime command property ‘from’ requires an explicit offset or Z.”**, substituting the normalized member. Array paths identify the precise failing value, but a frontend field named only `items` does not automatically match `items[2].when`. ## Require explicit date-time offsets Local date-time text is ambiguous when a rule compares instants. Opt in on the properties whose wire values must identify an instant. This DTO declaration works in an existing model-bound command or as a controller body; the marker does not itself expose an endpoint: ```csharp using System; using Cratis.Arc.Commands; public record TimeWindow( [property: ExplicitOffset] DateTimeOffset From, [property: ExplicitOffset] DateTimeOffset To); ``` Use the `property:` target on positional records. The attribute accepts properties and fields, not method or constructor parameters. It supports `DateTimeOffset`, `DateTime`, their nullable forms, and concepts derived from `ConceptAs` or `ConceptAs` (including nullable concept references). Concept binding uses the concept converter configured by `ConfigureArcDefaults()`. `DateOnly` and concepts wrapping it are unsupported because calendar dates have no offset. An unsupported type raises `ExplicitOffsetRequiresDateTimeOrDateTimeOffset` when JSON metadata is resolved. Accepted strings use an uppercase `T`, hours and minutes, optional seconds with up to seven fractional digits, and either an offset such as `+02:00` or uppercase `Z`: * `2026-10-16T12:30+02:00` * `2026-10-16T10:30Z` * `2026-10-16T12:30:00.1234567+02:00` Date-only text, missing offsets, lowercase `t`/`z`, and more than seven fractional digits are refused. Wrong token types and null for a non-nullable date-time value type produce generic malformed-request results. Nullable date-time properties and nullable concept references accept JSON null. `DateTimeOffset` keeps its supplied offset and tick precision; its comparisons already compare instants. `DateTime` becomes UTC with `Kind == Utc`. Concepts wrapping these types follow the same rules and serialize as date-time strings, not objects containing a `value` property. Writing uses the normal JSON date-time string representation. This does not add date-ordering rules: use server validation for the comparison. Generated clients serialize JavaScript `Date` values with `toISOString()` (including `Z`), but JavaScript retains only milliseconds; [date ordering stays server-side](/arc/backend/csharp/proxy-generation/validation/#fluentvalidation-rule-reference), with no client clock rule. The marker applies to JSON-bound properties, including nested collection elements. It does not change query-string or route-scalar binding, and unmarked properties keep their existing wire behavior. System.Text.Json source-generated metadata does not support derived `JsonConverterAttribute` attributes; use reflection-based metadata for these properties rather than assuming an application-supplied JSON context honors the marker. ## Controller differences ASP.NET Core authorization already runs before MVC binding. MVC controls formatter messages and response short-circuiting. Arc normalizes formatter ModelState keys such as `$.from` into member `from`; the explicit-offset formatter message remains **“The value requires an explicit offset or Z.”** rather than the model-bound member-specific message. An `[ApiController]` automatic ProblemDetails response can short-circuit before Arc’s action filter, so it is not guaranteed to use Arc’s command-result envelope. See [controller-based commands](/arc/backend/csharp/commands/controller-based/) and [pre-flight validation](/arc/backend/csharp/commands/command-validation/) for those separate execution paths. # Model-bound commands > Define standalone Arc commands with service dependencies and ordinary response values. When an operation needs an HTTP endpoint and a typed client, put its input and behavior together. Arc discovers a `[Command]` type with a public instance `Handle()` method and calls that method through the command pipeline. No event store is required. ## Model cart values Give cart values names that express their purpose: `CartLineId` identifies a line, `Sku` identifies a product, and `Quantity` counts units. These types make service signatures readable and let the C# compiler catch accidental swaps with unrelated identifiers or values. Define these shared concepts once in your cart feature, each in its own file: ```csharp using System; using Cratis.Concepts; public record CartLineId(Guid Value) : ConceptAs(Value) { public static readonly CartLineId NotSet = new(Guid.Empty); public static CartLineId New() => new(Guid.NewGuid()); public static implicit operator CartLineId(Guid value) => new(value); } public record Sku(string Value) : ConceptAs(Value) { public static readonly Sku NotSet = new(string.Empty); public static implicit operator Sku(string value) => new(value); } public record Quantity(int Value) : ConceptAs(Value) { public static readonly Quantity NotSet = new(0); public static implicit operator Quantity(int value) => new(value); } ``` Arc serializes concepts as their underlying values; the proxy generator maps them to TypeScript value types, not nominal concept classes. The named types protect the C# domain model without adding a wrapper object to the wire contract. ## A service-backed command Separate a decision from its effects The direct service call below is supported. When the command should describe work rather than perform it inside `Handle()`, prefer a [command operation](/arc/backend/csharp/commands/operations/implementing/). Its optional `Compensate()` lets Arc manage recovery; [operation specs](/arc/backend/csharp/testing/command-operations/) can test the decision and the real pipeline independently. The following application types reuse the shared concepts above and store cart lines **in memory for demonstration**, not in durable storage. Add them to your Arc application’s namespace and register `ICartService` as shown below. Declaration snippets omit that application-specific namespace; startup and caller fragments are labeled separately. The returned `CartLineId` is an ordinary response, not an event-source identity. ```csharp using System; using System.Collections.Concurrent; using Cratis.Arc.Commands.ModelBound; public record CartLine(Sku Sku, Quantity Quantity); public interface ICartService { CartLineId AddItem(Sku sku, Quantity quantity); } public class InMemoryCartService : ICartService { readonly ConcurrentDictionary _lines = new(); public CartLineId AddItem(Sku sku, Quantity quantity) { ArgumentException.ThrowIfNullOrWhiteSpace(sku.Value); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity.Value); var id = CartLineId.New(); if (!_lines.TryAdd(id, new CartLine(sku, quantity))) { throw new InvalidOperationException("Could not allocate a cart line identifier."); } return id; } } [Command] public record AddItemToCart(Sku Sku, Quantity Quantity) { public CartLineId Handle(ICartService carts) => carts.AddItem(Sku, Quantity); } ``` The store actually retains each line for the lifetime of its service instance. This intentionally small sample has no multi-user cart or authorization model; it is not a production shopping-cart implementation. Add [input validation](/arc/backend/csharp/commands/validation/) for friendly rejection messages and [authorization](/arc/backend/csharp/commands/model-bound/authorization/) before exposing protected operations. Registration fragment in your existing Arc application’s startup, with `Microsoft.Extensions.DependencyInjection` imported: ```csharp builder.Services.AddSingleton(); ``` At the execution checkpoint, inject `ICommandPipeline` into your caller and await it: ```csharp var result = await pipeline.Execute(new AddItemToCart("BOOK-1", 2)); ``` For valid input, `result.IsSuccess` is `true` and `result.Response` is the new line identifier. The registration and call are host fragments, not separate programs. See [programmatic execution](/arc/backend/csharp/commands/command-pipeline/) for scope and failure handling. ## Return types | Handler return | Pipeline behavior | | --- | --- | | `void` or `Task` | No response value. Arc awaits a returned task before completing the command. | | `T` or `Task` | A value with no matching response handler becomes the response. | | `Result` | Arc processes the active alternative; its name alone does not make it a failure. | | `ICommandOperation` implementation | Arc executes the returned operation on the server; it is not the response. | | `CommandOperations` | Explicit ordered batch of zero-to-many operations. | | Tuple | Operations and other handled values are processed on the server; at most one unhandled value becomes the response. | `Result` belongs to **`Cratis.Monads`**, not the `OneOf` namespace. Arc also understands `OneOf` wrappers. A singular `Cratis.Arc.Validation.ValidationResult` has a built-in handler; an arbitrary error record does not automatically mark the command unsuccessful. Do not return several unhandled tuple values: the pipeline reports `MultipleUnhandledTupleValues` as an exception-bearing command result. ## Result with tuple alternatives Use a tuple alternative when success needs both a caller response and a server-side handled value. In `Result<(Guid, AllocationLog), ValidationResult>`, Arc processes the active success tuple by returning the `Guid` and handling the `AllocationLog`. An active `ValidationResult` error instead records a validation failure without processing the success tuple. For the complete standalone command and required custom handler, see the [canonical example](/arc/backend/csharp/commands/response-value-handlers/#creating-custom-value-handlers). The [response value handling overview](/arc/backend/csharp/commands/response-value-handlers/#result-with-tuple-alternatives) explains this pattern. ## Dependencies and provided data Arc resolves `Handle()` service parameters from the command’s service scope. A `Provide()` method can fetch data after filters succeed and pass it to `Handle()`. Keep fetching separate when that makes the handler easier to test; a service-backed `Handle()` is equally valid. For the write side of that separation, return [command operations](/arc/backend/csharp/commands/operations/): `Provide()` acquires inputs, `Handle()` decides, and the operations perform the declared work. `CancellationToken` is special: Arc supplies the execution token rather than resolving it from DI. Both `Provide()` and `Handle()` may accept it. HTTP execution uses the request-aborted token; direct callers can [pass cancellation to the pipeline](/arc/backend/csharp/commands/command-pipeline/#cancellation). For the full `Provide()` contract, including its distinct validation control values, see [provide data to a command](/arc/scenarios/provide-data-to-a-command/). ## Frontend integration The [proxy generator](/arc/backend/csharp/proxy-generation/) uses the command type name for the generated TypeScript command. It generates typed properties, supported validation rules, and response handling. Build the backend before consuming the proxy; use [commands in React](/arc/frontend/react/commands/) to call it. [Chronicle command integration](/arc/backend/csharp/chronicle/commands/) is optional. Its returned-event behavior is an integration contract, not a requirement for a model-bound Arc command. # Model-bound command authorization > Protect standalone Arc commands with authentication, roles, and authorization filters. A valid command is not necessarily an allowed command. Arc evaluates authorization before ordinary command filters and does not invoke `Provide()` or `Handle()` when authorization is denied. ## Using the Authorize attribute Use the attributes in **`Cratis.Arc.Authorization`** for model-bound commands. This complete example requires authentication and returns an identifier without persisting a business record: ```csharp using System; using Cratis.Arc.Commands.ModelBound; [Command] [Cratis.Arc.Authorization.Authorize] public record AllocatePrivateIdentifier() { public Guid Handle() => Guid.NewGuid(); } ``` Configure authentication in your host to establish the request principal. The attribute checks that principal; it does not authenticate a request by itself. ## Using the Roles attribute This complete command requires an authenticated principal in at least one of the listed roles: ```csharp using System; using Cratis.Arc.Commands.ModelBound; public enum ApplicationRole { Admin, Manager } [Command] [Cratis.Arc.Authorization.Roles(nameof(ApplicationRole.Admin), nameof(ApplicationRole.Manager))] public record AllocateManagedIdentifier() { public Guid Handle() => Guid.NewGuid(); } ``` `[Cratis.Arc.Authorization.Authorize(Roles = $"{nameof(ApplicationRole.Admin)},{nameof(ApplicationRole.Manager)}")]` expresses the same role requirement. These application-owned names still emit the role strings `Admin` and `Manager`; preserve exact names when roles come from an external identity provider. A comma-separated list means *any one* of those roles. Several authorization attributes on one command all apply, so `[Roles(nameof(ApplicationRole.Admin))]` together with `[Authorize(Roles = nameof(ApplicationRole.Auditor))]` requires both. ## Anonymous access with AllowAnonymous `[Cratis.Arc.Authorization.AllowAnonymous]` bypasses Arc’s built-in attribute authorization check for that type. With no authorization requirement, the built-in evaluator also allows access. Mark intentionally public commands explicitly, and review their abuse controls separately. This does not promise to bypass custom authorization filters or external middleware. Likewise, adding an attribute to an unrelated controller does not establish a global authorization requirement for model-bound command types. ## Policy-based authorization Register a named, scoped `IAuthorizationPolicy` as shown in [Core named policies](/arc/backend/csharp/core/authorization/#register-a-named-policy), then protect a command using `[Cratis.Arc.Authorization.Authorize(Policy = "ActiveSubscription")]`. Arc awaits the policy before running `Provide()` or `Handle()`. Stacked policy attributes all apply. An unknown or duplicate policy fails host startup rather than admitting callers. `AuthorizationPolicyContext.Resource` is the executing `CommandContext`; the policy sees the selected caller through `Principal`. Both contexts carry the same `ReceivedAt` (Arc receipt time). It does not change when policy evaluation is delayed or repeated. See [command context](/arc/backend/csharp/commands/command-context/#properties) for the capture boundary and validator access. On an ASP.NET Core host, you can also use a named ASP.NET policy and `AuthenticationSchemes` (including schemes contributed by a policy). Arc authenticates the requested schemes, not just the default principal. A changed identity executes in a fresh Arc-owned service scope, so command values, execution-scope `Begin`, and handler dependencies bind to the selected tenant. An arbitrary explicit `IServiceProvider` may already hold dependencies for the default identity and is rejected for a scheme-selected command; use mapped HTTP or the command pipeline’s scope-owning `Execute(command)` overload from a live request. Ordinary nested server-side commands with the same no-scheme actor remain supported; a nested scheme that changes identity after an outer command has started its unit of work is rejected before it can join that work. Every named scheme must be registered; a request without an authenticated selected scheme is denied. The standalone Core host rejects `AuthenticationSchemes` at startup (see [ARC0021](/arc/backend/csharp/code-analysis/#arc0021-unevaluated-authorization-settings)). With `Cratis.Arc`, `Microsoft.AspNetCore.Authorization.AuthorizeAttribute` and `AllowAnonymousAttribute` on a model-bound command are enforced like Arc’s own - authentication, roles, policies, and schemes. Arc’s attributes are still the portable choice, because Arc Core’s own HTTP host does not read the Microsoft ones; analyzer [ARC0020](/arc/backend/csharp/code-analysis/#arc0020-aspnet-core-authorization-attributes) reports a Microsoft attribute in a project without `Cratis.Arc`. Standard Microsoft authorization on **MVC controllers/actions**, or explicitly configured external middleware, is a separate enforcement path. For domain-specific access control in the model-bound pipeline, implement an [authorization command filter](/arc/backend/csharp/commands/command-filters/#cross-cutting-authorization-by-namespace) using `IAuthorizationCommandFilter` and an actual unauthorized verdict. That example uses real authentication and role checks, not a placeholder policy or an overridable validator. Record ownership needs its own real resource lookup and owner comparison before changes occur. Test protection through every entry point your application exposes. ## Unbindable command bodies For execute and `/validate`, Arc checks authentication, roles, schemes, and fallback requirements before reporting malformed input. Named ASP.NET policies also run with a null resource when every resolved requirement is resource-free: the built-in authentication, role, claim and name requirements, or custom requirements implementing `Cratis.Arc.Authorization.IResourceFreeRequirement`. Implement this marker only for handlers that read the user and never the resource; a command-property comparison must not opt in. `AssertionRequirement` is not classified as resource-free. If any policy contains a resource-dependent requirement, Arc skips the policy set on this path, not just the unsafe handler. The binding failure stands as Validation for callers meeting the remaining authentication/role checks. Native Core policies and `IAuthorizationCommandFilter` implementations also need an instance and are not evaluated. Successfully bound commands still run every policy and filter. See [command input binding](/arc/backend/csharp/commands/input-binding/) for messages, member paths, and the explicit-offset marker. ## Authorization results Caller fragment with `ICommandPipeline pipeline` injected: ```csharp var result = await pipeline.Execute(new AllocateManagedIdentifier()); if (!result.IsAuthorized) { Console.WriteLine("Access denied"); } ``` A denied result has `IsSuccess == false` and the handler has not executed. Check `IsSuccess`, not `IsValid` alone, before using a response. [Validation severity](/arc/backend/csharp/commands/validation-severity-filtering/) does not override an unauthorized verdict. ## Executing commands from server-side code Arc’s `ICurrentPrincipalAccessor` uses the HTTP request principal when a request exists. Otherwise it uses a principal established through `ISystemExecution`, or null if none exists. A protected background command therefore needs an explicitly trusted server-side actor. This complete caller reuses `AllocateManagedIdentifier` above and requires `ISystemExecution` and `ICommandPipeline` from Arc DI: ```csharp using System; using System.Threading.Tasks; using Cratis.Arc.Authorization; using Cratis.Arc.Commands; public class ManagedIdentifierJob(ISystemExecution systemExecution, ICommandPipeline pipeline) { public async Task> Run() { using (systemExecution.AsSystem(nameof(ApplicationRole.Manager))) { return await pipeline.Execute(new AllocateManagedIdentifier()); } } } ``` `AsSystem(params string[] roles)` creates an authenticated system actor carrying exactly those roles. No roles satisfies authentication alone, not a role requirement. `As(ClaimsPrincipal)` supports an explicitly supplied principal. Dispose the scope to restore the previous actor, and await execution inside it. Never derive the system roles from request input. An HTTP request principal remains authoritative; a server-side scope cannot elevate that request. For the separate, optional event-sourced use case, see [Chronicle reactor command side effects](/arc/backend/csharp/chronicle/reactors/command-side-effects/) and [Chronicle command integration](/arc/backend/csharp/chronicle/commands/). # Command operations > Keep command decisions free of side effects while Arc executes and, when safe, compensates the work they declare. A booking command often needs to decide **which seat to reserve** and then call a reservation service. Mixing those responsibilities makes a decision test depend on network substitutes, and adding a second external call soon introduces a hand-written cleanup stack. Command operations separate the decision from the work. Your `Handle()` returns immutable descriptions of what should happen. Arc executes them, handles failures, and invokes an optional business reversal when the command’s commit boundary permits recovery. **Command operations are the recommended way to express immediate, inline side effects in model-bound commands.** Direct service calls remain supported; operations are useful when you want an inspectable decision, consistent execution, and framework-managed compensation. They require neither Chronicle nor a separate operation-handler class. ## Decide first, execute afterward An operation implements `ICommandOperation`. Its public `Execute()` method performs the work; its optional `Compensate()` method describes the reversal. Both receive services through method-parameter injection. Your command constructs the operation without invoking either method. Calling `Handle()` directly therefore lets a spec inspect the proposed work without reserving a seat. Calling the command through Arc executes the operation. [CommandScenario](/arc/backend/csharp/testing/command-operations/) follows the same production pipeline; it does not silently replace execution with a recording stub. This extends the separation already offered by [Provide()](/arc/scenarios/provide-data-to-a-command/): ``` Provide: acquire Handle: decide Execute operations Complete scopes Return result ``` The decision is pure only when it has no I/O, clock reads, randomness, or mutable external state. Returning an operation does not make an otherwise impure handler pure. Supply the identity and state the decision needs rather than creating hidden dependencies inside `Handle()`. Continue with [implementing an operation](/arc/backend/csharp/commands/operations/implementing/) for a complete reservation example. If you already have a service-backed handler, [migrate its inline work](/arc/backend/csharp/commands/operations/migrating/) while preserving the command’s input and caller response. ## You write the reversal, not the error handling For a reservation, the reversal is canceling the reservation owned by that request. Arc takes care of tracking started operations, stopping after a failure, selecting recovery, and running compensators in reverse order. You do not need a `try/catch`, a rollback stack, a success flag, or a switch identifying which operation failed. If an operation does not have a useful reversal, omit `Compensate()`; Arc does not invent one or report that it reversed the work. Consider four returned operations. A and B complete, C enters `Execute()` and throws, and D never starts. When compensation is permitted, Arc attempts C, then B, then A. It never compensates D. Why include C? A remote service may have accepted C’s request before the connection failed. A thrown exception proves that the local invocation failed—not that the external system did nothing. Compensation applies to attempted work A compensator must be safe when its operation was partially applied or its acknowledgment was lost. Cancel by an ownership-aware request key. A simple “delete if present” can race a create request still in flight; your provider must supply appropriate terminal cancellation or reconciliation semantics. Duplicate requests must also protect previously finalized work: a new failed attempt must not cancel an earlier successful reservation because they share a key. The [execution reference](/arc/backend/csharp/commands/operations/reference/) describes ordering, cancellation, and the optional failure context without making those concerns mandatory application boilerplate. ## Compensation is not an atomic rollback A compensating action is new work. Releasing a reservation does not erase the fact that it was reserved; refunding money does not mean a payment never happened. With Chronicle, returned events are enrolled before operations execute, and the event transaction completes afterward. A known commit rejection can permit compensation. A successful commit followed by an unrelated cleanup failure must not automatically cancel reservations associated with already-committed business facts. An unknown commit outcome is different again. A timeout may mean that the store committed but its acknowledgment was lost. Arc does not infer “nothing committed” from a failed `CommandResult`, and it does not automatically retry or reverse operations while commitment is uncertain. Recovery stays on the backend Arc handles operation failures and compensation on the server. Backend callers and scenarios can inspect recovery observations; the existing HTTP and TypeScript command-result contract stays unchanged. Clients receive the ordinary command success, validation, exception, and response behavior—not operation descriptors or a rollback protocol to implement. Compensation is best-effort and in-process. It is not a durable queue, a distributed transaction, or recovery after a process crash. Read the [commit and recovery contract](/arc/backend/csharp/commands/operations/reference/#commit-and-recovery) before relying on reversal. ## Choose the right boundary | Your need | Prefer | | --- | --- | | Acquire data for a decision | `Provide()` or a directly supplied decision input | | Reject invalid input or a business decision | A validator or a recognized `ValidationResult` alternative | | Return information to the caller | An ordinary command response | | Perform immediate external work chosen by a command | A command operation | | Reverse an attempted operation when the command fails safely before commitment | Its optional `Compensate()` method | | Record a durable domain fact with Chronicle | Return an event | | React reliably to an already-committed fact | A Chronicle reactor or an appropriate durable workflow/outbox | | Obtain an external call’s result to make the decision itself | An explicit service interaction; a post-decision operation cannot supply that earlier input | | Extend the framework’s interpretation of specialized return types | A [response value handler](/arc/backend/csharp/commands/response-value-handlers/) | Do not replace durable email delivery, payment workflows, or retriable webhooks with inline operations merely to shorten a handler. Choose the delivery and reconciliation guarantees first. [Reactor-returned commands](/arc/backend/csharp/chronicle/reactors/command-side-effects/) are a separate composition mechanism; a reactor retry can execute its command operations again. ## What to test Three boundaries answer three different questions: 1. **Decision:** call `Handle()` and assert the returned operation data and order. 2. **Operation adapter:** call `Execute()` or `Compensate()` with a substitute and verify the provider request. 3. **Arc composition:** use `CommandScenario` with controlled dependencies to prove execution, failure, compensation, and the resulting command outcome. None proves that a production reservation provider has correct idempotency or cancellation semantics. Test that provider at its own integration boundary. Follow [testing command operations](/arc/backend/csharp/testing/command-operations/) to turn this separation into executable specifications. # Implement a command operation > Return a reservation operation from a pure command decision and let Arc execute and compensate it without application error-handling boilerplate. **Goal:** a command returns the seat reservation it intends to make, while Arc performs the reservation and manages an optional cancellation if later execution fails safely before commitment. Use this recipe in an application configured with `Cratis.Arc.Core` or `Cratis.Arc`. Chronicle is optional. You need an application reservation provider; the [testing lesson](/arc/backend/csharp/testing/command-operations/) uses a substitute so you can exercise the same contract without an external service. ## Define the operation’s inputs and provider Add the following application declarations. Keep them in the same `SeatBooking` namespace; shared concepts normally live in separate files. `ReservationId` is an ordinary strongly typed request identifier, not a Chronicle event-source identity. Reservation.cs ```csharp using System; using System.Threading; using System.Threading.Tasks; using Cratis.Concepts; namespace SeatBooking; public record ReservationId(Guid Value) : ConceptAs(Value) { public static readonly ReservationId NotSet = new(Guid.Empty); public static implicit operator ReservationId(Guid value) => new(value); } public record SeatId(string Value) : ConceptAs(Value) { public static readonly SeatId NotSet = new(string.Empty); public static implicit operator SeatId(string value) => new(value); } public interface ISeatReservations { Task Reserve( ReservationId reservationId, SeatId seatId, CancellationToken cancellationToken); Task Cancel(ReservationId reservationId, CancellationToken cancellationToken); } ``` `Reserve` and `Cancel` belong to your provider, not Arc. Register its implementation as `ISeatReservations` through the host’s normal dependency injection. No operation executor or response-value-handler registration is needed. The provider must enforce ownership and make cancellation meaningful for an attempted reservation. Use a request identifier within the correct tenant and owner’s scope. Bind that identifier immutably to its intended request, reject conflicting reuse, and protect previously finalized reservations from a later attempt’s compensation. A request identifier is neither authorization to cancel somebody else’s reservation nor proof that the current invocation created an existing reservation. If a duplicate request returns an earlier successful reservation, cancellation must not destroy that finalized success when some other operation in the new command attempt fails. Verify this contract with the production provider. Arc supplies orchestration, not a universal idempotency or reservation-ownership protocol. ## Pair execution with its business reversal Add the operation in the same project: ReserveSeat.cs ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; namespace SeatBooking; public sealed record ReserveSeat(ReservationId ReservationId, SeatId SeatId) : ICommandOperation { public Task Execute( ISeatReservations reservations, CancellationToken cancellationToken) => reservations.Reserve(ReservationId, SeatId, cancellationToken); public Task Compensate( ISeatReservations reservations, CancellationToken cancellationToken) => reservations.Cancel(ReservationId, cancellationToken); } ``` Arc discovers the operation’s method shape and supplies the scoped service and cancellation token. The operation carries only business data; its methods receive infrastructure when invoked. The normal build includes diagnostics for invalid declarations and generated invocation support. There is no `try/catch` or success flag. Arc decides whether to compensate, including when this operation’s `Execute()` threw after possibly changing the remote system. The provider’s cancellation protocol must accommodate that uncertainty. Omit `Compensate()` when there is no meaningful reversal. Arc still executes and reports the operation, but cannot claim to have reversed it after a failure. ## Return the declaration from a command Add a model-bound command: BookSeat.cs ```csharp using Cratis.Arc.Commands.ModelBound; namespace SeatBooking; [Command] public record BookSeat(ReservationId ReservationId, SeatId SeatId) { public (ReservationId Response, ReserveSeat Operation) Handle() => (ReservationId, new ReserveSeat(ReservationId, SeatId)); } ``` `Handle()` constructs an operation; it does not reserve the seat. The same inputs produce the same returned values, without I/O, a clock, or random identity generation inside the decision. The caller supplies the request identifier. Arc recognizes the operation as server-consumed work. It invokes `Execute()` and exposes only the `ReservationId` response. Your generated TypeScript command has no `ReserveSeat` response object or compensation method to call. Apply your normal [validation](/arc/backend/csharp/commands/validation/) and [authorization](/arc/backend/csharp/commands/model-bound/authorization/). Operations do not replace either, and a direct call to `Handle()` does not run those pipeline checks. ## Execute through the normal pipeline This caller uses the shared declarations above. Register the application provider before resolving it from an Arc-configured service scope: BookingRequests.cs ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; namespace SeatBooking; public class BookingRequests(ICommandPipeline pipeline) { public Task> Book( ReservationId reservationId, SeatId seatId, CancellationToken cancellationToken) => pipeline.Execute( new BookSeat(reservationId, seatId), cancellationToken); } ``` Awaiting `Book()` waits for operation execution, scope completion, and any eligible compensation. On success, `IsSuccess` is true and `Response` contains the reservation identifier. On failure, Arc returns an unsuccessful result and does not require the caller to implement a rollback stack. **Calling `Handle()` directly does not execute the operation.** Use that direct call for a decision spec, not as a substitute for `ICommandPipeline` in production. ## Return zero or many operations An optional singular operation can be null: BookSeatIfNeeded.cs ```csharp using Cratis.Arc.Commands.ModelBound; namespace SeatBooking; [Command] public record BookSeatIfNeeded( ReservationId ReservationId, SeatId SeatId, bool ShouldReserve) { public ReserveSeat? Handle() => ShouldReserve ? new ReserveSeat(ReservationId, SeatId) : null; } ``` Arc skips the absent operation. This command has no client response. For a variable number of operations, use `CommandOperations` rather than returning a raw array or enumerable: BookSeats.cs ```csharp using System.Collections.Generic; using System.Linq; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; namespace SeatBooking; public record SeatReservation(ReservationId ReservationId, SeatId SeatId); [Command] public record BookSeats(IReadOnlyList Reservations) { public CommandOperations Handle() => [ .. Reservations.Select(reservation => new ReserveSeat(reservation.ReservationId, reservation.SeatId)) ]; } ``` An empty input produces an empty batch. Membership is materialized once, and operations execute sequentially in the given order. Different operation types can share the same `CommandOperations` batch. Use a distinct ownership key for each logical reservation. Ordinary arrays and enumerables remain valid response data; the explicit wrapper tells Arc that you mean executable work. For a fixed set of different operations, a tuple is also supported. ## Check the recovery boundary before deploying This initial operation contract supports flat, sequential commands and compatible execution scopes, with at most one deferred commit participant. Nested operation-bearing commands, receipt-returning execution methods, automatic retries, and durable recovery are not part of this contract. With Chronicle, return events for deferred completion. Do not explicitly commit an aggregate and then expect a later operation failure to undo that commit. An unknown or already-committed business outcome suppresses automatic reversal; [the reference](/arc/backend/csharp/commands/operations/reference/#commit-and-recovery) explains the reporting. Cancellation is not proof that nothing happened A timeout or disconnect may arrive after the provider reserved the seat. A compensator that merely checks for absence can race a still-running request. The provider must offer a cancellation or reconciliation protocol appropriate to that behavior. Arc cannot turn two external calls into one atomic transaction. Next, [test the declaration, execution, and compensation](/arc/backend/csharp/testing/command-operations/) through direct specs and `CommandScenario` before connecting a production provider. # Move inline work to a command operation > Migrate a supported service-backed command to a pure decision and returned operation without changing its input or client response contract. **Goal:** move an inline service write out of `Handle()` while keeping the same command input, endpoint, and caller response. Direct service-backed handlers remain supported; migrate when the work can be described before it executes and the separation makes the decision easier to test. This recipe reuses the `ReservationId`, `SeatId`, `ISeatReservations`, and `ReserveSeat` declarations from [implementing an operation](/arc/backend/csharp/commands/operations/implementing/). The two command definitions below are alternatives: compile the replacement instead of the original, not alongside it. ## Identify what the decision needs Separate three concerns before moving code: * **Inputs to the decision:** values on the command, read models, or data acquired through `Provide()`. * **The decision:** which operations and domain facts should result from those inputs. * **Execution:** the service writes performed by the returned operations. If the provider creates an identifier or returns information that the decision or response needs, do not hide that write inside `Provide()` to make `Handle()` appear pure. Operation execution does not return a receipt into an already-constructed response. Keep the direct service call when it is the right fit, or deliberately redesign the provider contract to accept an identifier allocated before the decision. Do not change identity or persistence semantics solely for syntactic purity. ## Replace the forward call with a declaration The supported original command performs the reservation itself: ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands.ModelBound; namespace SeatBooking; [Command] public record BookSeat(ReservationId ReservationId, SeatId SeatId) { public async Task Handle( ISeatReservations reservations, CancellationToken cancellationToken) { await reservations.Reserve(ReservationId, SeatId, cancellationToken); return ReservationId; } } ``` Replace that command definition with: ```csharp using Cratis.Arc.Commands.ModelBound; namespace SeatBooking; [Command] public record BookSeat(ReservationId ReservationId, SeatId SeatId) { public (ReservationId Response, ReserveSeat Operation) Handle() => (ReservationId, new ReserveSeat(ReservationId, SeatId)); } ``` The response identifier is already an input, so returning it requires no provider result. The command now describes the reservation, and Arc executes the existing `ReserveSeat` operation through the same model-bound pipeline. Keep the existing `ISeatReservations` registration; add no separate operation-handler registration. The command name and input properties have not changed. After rebuilding the backend, the generated proxy still exposes a `ReservationId` response—not the tuple or operation. Direct C# callers of `Handle()` now receive a declaration; callers using `ICommandPipeline` continue to receive the actual execution result. Remove the old execution path Do not call `Reserve()` in `Handle()` and also return `ReserveSeat`. Do not manually call the operation’s `Execute()` before returning it. Either would perform the work twice. Moving the same write into `Provide()` also leaves it outside operation recovery. ## Preserve the right extension boundary If a custom `ICommandResponseValueHandler` exists only to perform this application side effect, the operation can replace that handler and its typed proxy marker. Remove the unused handler declaration rather than retaining redundant discovery and dependency requirements. Operation values are reserved for Arc’s operation phase, not dispatched to ordinary response handlers again. Keep specialized response-value handlers for framework integrations and return-value interpretation. Keep returned Chronicle events for durable domain facts; do not wrap event appends in operations to bypass the Chronicle append contract. Keep reactors, outboxes, and durable workflows when the work must follow committed facts reliably. A controller action does not become an operation-aware command by returning an operation object. Use the [model-bound pipeline](/arc/backend/csharp/commands/command-pipeline/), or keep the controller’s existing service interaction when its HTTP behavior is the requirement. ## Check scopes and compensation before enabling the new path An operation-bearing command uses the [supported scope profile](/arc/backend/csharp/commands/operations/reference/#supported-scope-profile): flat execution, explicitly compatible scopes, and at most one deferred commit participant. Audit custom execution scopes and explicit aggregate commits before migrating. Do not label a committing scope as a nonparticipant merely to make the command run. Review `ReserveSeat.Compensate()` as a domain operation, not a generic delete-on-error callback. The provider must protect ownership, conflicting request-key reuse, and previously finalized work, including when a reservation acknowledgment was lost. If a reversal is not meaningful, omit it and accept that failed execution may leave uncompensated work. Arc manages failure handling and recovery selection; you do not add a `try/catch` or rollback stack to the command. ## Prove that behavior was preserved Use [operation specifications](/arc/backend/csharp/testing/command-operations/) at both boundaries: 1. Call `Handle()` directly and assert the response identifier and operation data. No provider should be needed for this decision spec. 2. Execute through `CommandScenario`, register a controlled provider, and assert the actual provider arguments and caller response. 3. Inject a forward failure and assert the original failure, stopped execution, and eligible compensation—not only an unsuccessful result. 4. Keep validation and authorization specs. Returning an operation does not replace either concern. 5. With Chronicle, [exercise a real commit rejection](/arc/backend/csharp/testing/command-operations-with-chronicle/) and verify that rejected events do not persist. Review retry behavior in the injected provider as well. Arc does not automatically retry operations, but an HTTP resilience policy, database execution strategy, or driver may retry inside a provider call. Those policies still need ownership-safe idempotency. The migration is complete when the response and provider effects are preserved, the decision is independently testable, and the recovery boundary is explicit. For unexpected outcomes, use the [troubleshooting reference](/arc/backend/csharp/commands/operations/reference/#troubleshooting). # Command operation execution and recovery > Look up operation signatures, ordering, compensation eligibility, backend diagnostics, cancellation, and supported command boundaries. This reference describes `ICommandOperation` in `Cratis.Arc.Commands`. Start with [command operations](/arc/backend/csharp/commands/operations/) for the design rationale or [implementing an operation](/arc/backend/csharp/commands/operations/implementing/) for a complete example. ## Declaration contract | Member | Contract | | --- | --- | | `ICommandOperation` | Opts the returned value into command operation processing. An immutable record is the normal authoring shape. | | `Execute(...)` | Exactly one public, nongeneric instance method. | | `Compensate(...)` | Optional public, nongeneric instance method describing an attempted operation’s business reversal. | | Method result | `void`, `Task`, or `ValueTask`; Arc awaits asynchronous methods. | | Service parameter | Required dependency from the originating command’s service provider. | | `CancellationToken` parameter | Supplied by Arc; at most one per method. Execution and compensation use different cancellation lifetimes. | | `CommandOperationFailure` parameter | Optional immutable failure context, accepted only by `Compensate()`. | Value-returning methods, including `Task` and `ValueTask`, are not supported operation execution signatures. An execution receipt is not implicitly added to a response or event. Neither method returns more pipeline values. Do not use `async void`, static or generic methods, overloads, a service-locator parameter, optional service arguments, or by-reference parameters. Do not capture services inside the operation record. Business inputs belong in its properties; execution dependencies belong in method parameters. Operations may be reference or value types. Nullable value-type operations and nullable batches remain server-only when present; null means absent. Prefer `[]` or `default(CommandOperations)` when expressing an empty batch rather than introducing a nullable batch. The build diagnoses invalid conventions and generates typed invocation metadata. [ARC0016](/arc/backend/csharp/code-analysis/#arc0016-command-operation-methods) checks method shapes, [ARC0017](/arc/backend/csharp/code-analysis/#arc0017-command-operation-batches) rejects bare operation collections, and [ARC0018](/arc/backend/csharp/code-analysis/#arc0018-command-operation-visibility) checks generated-invoker accessibility. Validated reflection fallback supports source-free declarations where generated metadata is unavailable. Generated operation invocation is not a claim that the whole Arc host supports NativeAOT. ## Returned values | `Handle()` result | Treatment | | --- | --- | | Concrete operation or `ICommandOperation` | Execute the concrete operation on the server. | | Null singular operation | No invocation. | | `CommandOperations` | Expand the explicit batch in order. | | `default(CommandOperations)` or `[]` | Empty batch. | | Tuple | Combine operations, other server-handled values, and at most one client response. | | `Result` or `OneOf` | Process only the active alternative, including its tuple values. | | Task/ValueTask-wrapped supported return | Await the handler, then classify the result. | | Ordinary array or enumerable | Retains ordinary response-value classification; it is not a generic operation batch. | `CommandOperations` copies/materializes membership once and rejects null elements. It is not mutable after construction. Descriptor immutability remains the application’s responsibility. Operations are exclusively server-consumed. A broad custom response-value handler does not get to execute the same operation a second time. The proxy generator excludes operations and batches from the client response contract. Use a statically meaningful signature rather than hiding operations behind `object` or an arbitrary container. ## Execution and failure ordering 1. Arc runs authorization, validation, `Provide()`, and `Handle()`. 2. It classifies the return graph and validates operation metadata and dependencies before the first operation starts. 3. It processes control values and other server-consumed values, including returned Chronicle event enrollment. 4. If the command is still successful, it invokes operations sequentially in declaration order. 5. It records an invocation immediately before entering its `Execute()`, after dependency resolution and cancellation checks. 6. An execution failure or cancellation prevents later operations from starting. 7. Required execution scopes complete. Arc obtains conservative commitment facts before deciding on recovery. 8. Eligible compensators run in reverse invocation order. A compensation failure does not erase the original failure or make the command successful. 9. Arc finalizes backend observations and clears a selected response when the command is unsuccessful. The operation whose `Execute()` throws is a started invocation. Its receipt-independent compensator is eligible alongside earlier started operations. A descriptor never entered is not eligible. An exception during dependency preflight does not count as entering `Execute()`. A direct call to `Handle()`, `Execute()`, or `Compensate()` does not invoke this orchestration. It is a normal C# method call. Use `ICommandPipeline` or [CommandScenario](/arc/backend/csharp/testing/command-scenario/) to exercise the framework behavior. ## Commit and recovery Compensation is conditional on both command failure and the observed business boundary. A failed `CommandResult` alone does not establish that nothing committed. | `CommandCommitDisposition` | Recovery policy on failure | | --- | --- | | `NoCommit` | No coordinated commit participant exists; attempt declared compensation. | | `NotCommitted` | Coordinated changes are known not committed; attempt declared compensation. | | `Committed` | Suppress automatic reversal of work associated with committed business facts. | | `Unknown` | Do not guess or reverse automatically. Report indeterminate recovery. | | `Mixed` | No blanket reversal of partially committed work. Report indeterminate recovery. | With Chronicle, operations execute before automatic transaction completion. A known rejection of the returned-event transaction can permit compensation. A thrown commit with uncertain outcome cannot. Previously successful immediate appends and explicit commits cannot be rolled back as pending enrollment. Scope completion still runs after operation failure. Compensation does not replace the real rollback of pending Chronicle events or attempt to un-append them. Post-pipeline serialization, result delivery, process crashes, and arbitrary application writes outside returned operations are not covered. No operation journal survives a process crash. Indeterminate recovery is a report, not a promise that Arc scheduled a later attempt. ## Backend observations Backend `CommandResult` exposes `Recovery` and `OperationOutcomes`. These are excluded from HTTP JSON. They do not change the TypeScript command-result contract, and operation payloads are not returned to the client. `Recovery` is absent when operation processing does not participate. When present, `CommandRecoverySummary` contains: | Property | Meaning | | --- | --- | | `CommitDisposition` | Observed commitment facts, independent of `IsSuccess`. | | `Status` | Recovery decision and observed outcome. | | `StartedCount` | Number of `Execute()` invocations entered. | | `CompletedCount` | Number of `Execute()` methods that returned successfully. | | `CompensatedCount` | Number of `Compensate()` methods that returned successfully. | | `FailedCompensationCount` | Number of compensators that threw. | | `UncompensatedCount` | Started work requiring recovery that was not observed to complete compensation. | `CommandRecoveryStatus` is `NotNeeded`, `Completed`, `Incomplete`, `Suppressed`, or `Indeterminate`. `Completed` means the required callbacks returned; it does **not** prove that external history was atomically erased or that a retry is safe. `OperationOutcomes` contains server-only per-invocation observations: `InvocationIndex`, `OperationType`, `ExecutionCompleted`, `Compensation`, and an optional `CompensationFailure` message. These observations do not contain the operation’s business properties. `CommandOperationCompensation` distinguishes `NotNeeded`, `Completed`, `Failed`, `NotAvailable`, `BudgetExpired`, and `Suppressed`. Missing compensation is observable rather than silently labeled rollback. Use the command’s existing `CorrelationId` with server diagnostics. Keep recovery messages and operation types in trusted diagnostics rather than exposing them as application-facing error copy. ## Optional failure context `Compensate()` can request `CommandOperationFailure` when a specialized reversal needs execution observations. Ordinary operations do not need this argument. | Property | Meaning | | --- | --- | | `InvocationIndex` | Zero-based index of this invocation. | | `InvocationCompleted` | Whether its `Execute()` returned successfully. | | `IsFailingInvocation` | Whether this invocation threw the original execution failure. | | `Source` | Original failure phase: planning, response handling, execution, cancellation, or scope completion. | | `CommitDisposition` | Commitment facts used for the recovery decision. | | `ExceptionMessages` | Defensive snapshot of original exception messages, not a mutable `CommandResult`. | A false `InvocationCompleted` is not proof that an external service made no change. Use provider-supported reversal semantics rather than skipping cancellation solely because an invocation threw. Context properties cannot remove a failure or turn the command into success. ## Cancellation and recovery budget `Execute()` receives the command execution token. For HTTP execution this is normally the request-aborted token. Arc checks cancellation before starting another operation and forwards the token to the operation method. `Compensate()` receives a separate token so a disconnected client does not immediately cancel cleanup. `CommandOperationOptions.CompensationTimeout` defaults to thirty seconds and is shared by the recovery attempt. This is a host-configuration fragment; import `System`, `Cratis.Arc.Commands`, and `Microsoft.Extensions.DependencyInjection`: ```csharp builder.Services.Configure(options => options.CompensationTimeout = TimeSpan.FromSeconds(10)); ``` The budget is cooperative. A method that ignores cancellation is still awaited; Arc does not forcibly terminate it or dispose services underneath running code. Operations not entered before the recovery budget expires are reported rather than silently claimed compensated. There are no automatic retries of execution or compensation. Retrying an uncertain request and creating a new logical attempt after confirmed compensation have different idempotency requirements. A correlation ID or tuple index is not a universal external idempotency key. ## Supported scope profile The initial contract supports flat sequential commands, explicitly compatible execution scopes, and at most one deferred commit participant. Chronicle supplies its compatible integration. Nested operation-bearing commands, parallel/detached command participation, receipt signatures, and durable recovery are not supported. Attempting a same-host nested command from `Execute()` rejects the outer operation batch even if the operation ignores the child’s failed result. A nested command attempted from `Compensate()` is recorded as failed compensation; remaining eligible compensators still run. Calling `ICommandPipeline` from these methods does not bypass the flat-boundary contract. Custom execution scopes opt in through `ICommandOperationExecutionScope`, which extends `ICommandExecutionScope`: * `IsCommitParticipant` declares whether the scope coordinates a deferred business commit. * `GetCommitDisposition(CommandContext)` reports authoritative facts even after a completion failure; return `Unknown` when facts are unavailable. * A nonparticipant promises not to commit business changes. * `Begin()` must not commit business work. Do not mark a database-committing scope as a nonparticipant merely to satisfy validation. Undeclared scope behavior is not assumed safe. Existing scope completion order remains unchanged; a later scope failure can therefore follow a known commit and suppress compensation. Unsupported operation boundaries are rejected rather than silently falling back to application-authored cleanup. Arbitrary service writes performed outside declared operations remain outside this contract. ## Storage integrations and custom-scope checks | Integration | Operation boundary | | --- | --- | | Standalone Arc | No automatic database transaction. Operations execute registered application services. | | Chronicle | Compatible deferred event commitment, with conservative observations for rejected, committed, and uncertain outcomes. | | [EF Core](/arc/backend/csharp/entity-framework/) | No built-in operation commit participant. Context registration and read-model observation do not automatically coordinate operation writes or call `SaveChanges` for you. | | [MongoDB](/arc/backend/csharp/mongodb/) | No built-in operation commit participant. Collection/session access and resilience behavior remain provider concerns. | Do not combine independent EF and Chronicle commits under the single-participant profile and describe them as atomic. A provider-backed operation must retain its own ownership and idempotency guarantees. Database execution strategies and driver resilience may retry within a provider call even though Arc does not retry operations. Before declaring a custom scope compatible, specify these cases against its actual provider: * `Begin()` establishes its lifetime without committing business work; partial initialization does not make cleanup unsafe. * `IsCommitParticipant` is stable and describes the scope’s responsibility, not whether the latest result happened to succeed. * A confirmed rejection or rollback reports `NotCommitted`; an unverified commit acknowledgment reports `Unknown`. * A known successful commit remains committed when a later scope reports an error. * A completion exception still leaves commitment observations available. `CommandResult.IsSuccess` and a generic “completed” flag are not substitutes for those facts. * Recovery dependencies remain usable after scope completion. A still-live DI scope does not repair a failed transaction or database context; obtain usable recovery resources without losing the tenant or ownership context. * Cancellation and resource disposal do not interrupt recovery or dispose dependencies underneath running callbacks. Use the [Chronicle constraint example](/arc/backend/csharp/testing/command-operations-with-chronicle/) as a concrete test of a known rejection, not as proof that a different provider has identical transaction semantics. ## Troubleshooting | Symptom | What to inspect | | --- | --- | | Calling `Handle()` produces an operation but performs no work | This is a direct decision call. Use `ICommandPipeline` or `CommandScenario` for framework execution. | | No operations started | Check authorization, validation, `Provide()`, return classification, required services, declaration diagnostics, and scope compatibility before assuming `Execute()` ran. `Validate()` does not run operations. | | `Recovery` is absent | HTTP/TypeScript results intentionally omit it. On a backend result, rejection before operation processing or an absent operation can also leave it unset. | | A custom scope prevents execution | Implement the compatible scope contract only if the scope can honor it. Preserve real commit facts; do not report a database-writing scope as noncommitting. | | Recovery is `Incomplete` | Inspect `OperationOutcomes` for a missing compensator, a thrown compensation, or an exhausted cleanup budget. Not all started work was observed to be compensated. | | Recovery is `Suppressed` | Business changes are known committed. A later failure is not permission to undo operations associated with those facts. | | Recovery is `Indeterminate` | Commitment is unknown or mixed. Reconcile with the storage/provider boundary; Arc has not scheduled a durable retry or reversal. | | Cleanup continues after the request was canceled | Compensation has its own token. Its timeout is cooperative, so a provider that ignores cancellation can exceed the budget. | | Work repeats even though Arc does not retry | Inspect caller retries and provider/driver resilience policies. A provider can retry inside one `Execute()` invocation. | | An ignored nested-command failure still rejects the batch | The flat-boundary guard is intentional. Compose operation declarations instead of invoking another same-host command from an operation. | | No cleanup occurs after a process crash | The journal is in-memory. Use a durable reactor/outbox/workflow when recovery must survive process loss. | Use the command correlation ID and trusted backend observations for diagnosis. Do not infer successful rollback from a generic failed command result, or trigger automatic retries from `IsSuccess == false` alone. For application-level coverage of the actual execution and recovery path, follow [testing command operations](/arc/backend/csharp/testing/command-operations/), the [failure-case recipes](/arc/backend/csharp/testing/command-operation-failures/), and the [Chronicle commit-rejection example](/arc/backend/csharp/testing/command-operations-with-chronicle/). # Command response examples > Read ordinary values and distinguish validation failures from response data. Use the [service-backed command](/arc/backend/csharp/commands/model-bound/#a-service-backed-command) as the first checkpoint: `AddItemToCart.Handle()` returns a `CartLineId` concept. Use the [custom value handler example](/arc/backend/csharp/commands/response-value-handlers/#creating-custom-value-handlers) when you also need a non-event server-side value handled. The snippets below are caller fragments for those complete definitions, with `ICommandPipeline pipeline` available through dependency injection. They are not independent programs. ## Simple value response ```csharp var result = await pipeline.Execute(new AddItemToCart("BOOK-1", 2)); if (result.IsSuccess) { Console.WriteLine(result.Response); } ``` `Execute` returns `CommandResult`. The concept needs no custom response handler and is serialized as its underlying `Guid`. Ordinary primitive responses also work, as the identifier-allocation example below demonstrates; neither response implies an event-source identity. Always await execution before reading the result. ## Result and tuple response ```csharp var result = await pipeline.Execute(new AllocateIdentifier("invoice")); if (result.IsSuccess) { Console.WriteLine($"Allocated {result.Response}"); } ``` The active success alternative of `Result<(Guid, AllocationLog), ValidationResult>` contains a tuple. `AllocationLogHandler` consumes the log value; the `Guid` becomes the response. Without that handler, both values are unhandled and the pipeline reports an exception outcome. ## Singular validation result ```csharp var result = await pipeline.Execute(new AllocateIdentifier("")); foreach (var failure in result.ValidationResults) { Console.WriteLine(failure.Message); } ``` This prints `A label is required`. Arc’s `Cratis.Arc.Validation.ValidationResult` represents **one failed rule**. It has no `IsValid` property. `CommandResult.IsValid` checks the collection on the command result; FluentValidation’s separate `FluentValidation.Results.ValidationResult` is an aggregate with `IsValid` and `Errors`. Use a discovered `CommandValidator` for FluentValidation rules so Arc converts its failures. Do not return FluentValidation’s aggregate or an Arc validation array from `Handle()` expecting the singular Arc response handler to interpret it. See [validation](/arc/backend/csharp/commands/validation/) and the [response contract](/arc/backend/csharp/commands/response-value-handlers/#automatic-response-handling). ## Multiple unhandled values This complete command is a deliberately invalid response-contract example. With no handlers for `string` or `int`, both values compete to be the response: ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record AmbiguousResponse() { public (string, int) Handle() => ("first", 42); } ``` An awaited `pipeline.Execute(new AmbiguousResponse())` normally returns `HasExceptions == true`, carrying the `MultipleUnhandledTupleValues` failure. Instead, wrap related response fields in one response record, or provide a real handler for the value you intend to consume server-side. ## Failure and absent responses Without a response, the typed overload supplies `default(TResult)`: `null` for a reference type, `Guid.Empty` for `Guid`, and `0` for `int`. Failed execution clears an already selected response to the default of its concrete response type. Requesting that same type yields `default(TResult)`, including `Guid.Empty`. Currently, adapting a cleared value-type response to `object` or a compatible interface can preserve its boxed default instead of null: `Execute` can return a boxed `Guid.Empty` after scope completion fails. Always check `IsSuccess` before using the response. Likewise, naming an alternative `PaymentFailed` in a `Result` does not make it a failure: unless a handler interprets it, it is ordinary response data. See [typed command results](/arc/backend/csharp/commands/command-pipeline/#typed-command-results) for assignable response types and type mismatches, and [execution scopes](/arc/backend/csharp/commands/command-execution-scopes/) for failures after response processing. # Response value handlers > How standalone Arc classifies scalar, tuple, and discriminated-union command results. A command may need both a response for its caller and server-side processing of another value. Response value handlers separate those responsibilities without requiring event sourcing. Prefer command operations for application side effects Use [command operations](/arc/backend/csharp/commands/operations/) to declare inline service work with scoped dependencies and optional compensation. You do not need to implement the two response-handler interfaces or manage error handling yourself. Response value handlers remain the advanced extension point for framework integrations and specialized return-value interpretation; they are not deprecated. ## Automatic response handling Response value handlers apply to model-bound commands. Controller actions bypass this classification: Arc places the action’s returned value directly in `CommandResult.Response`. Generated controller proxies therefore do not suppress values declared as server-handled by typed or attributed handler markers, or by command operation contracts. After awaiting `Handle()`, the model-bound pipeline classifies its return value. `ICommandOperation` values and `CommandOperations` batches are reserved for the [operation phase](/arc/backend/csharp/commands/operations/reference/#execution-and-failure-ordering); ordinary response-value handlers do not execute them a second time. For other values: 1. A simple value matching `ICommandResponseValueHandler.CanHandle` is processed by every matching handler. Their `CommandResult` outcomes are merged. 2. A simple value with no handler becomes the response, including an ordinary `Guid`. 3. A `Result` or `OneOf` wrapper is unwrapped and its active value is processed. Nested wrappers are supported. 4. Tuple elements are classified individually. Exactly one unhandled value may become the response; all remaining non-null values need handlers. If all values are handled, there is no response. Multiple unhandled tuple values cause `MultipleUnhandledTupleValues` internally. `ICommandPipeline.Execute` catches this and normally returns an exception-bearing result, rather than throwing that exception to the caller. A null return supplies no response. The built-in handler consumes **one `Cratis.Arc.Validation.ValidationResult`**. It does not consume `ValidationResult[]` or `IEnumerable` returned by `Handle()`. An unhandled collection becomes ordinary response data, or conflicts with another unhandled tuple value. `Provide()` has a [different control-result contract](/arc/scenarios/provide-data-to-a-command/) that does recognize validation collections. ## Result with tuple alternatives A `Result` can use a tuple as an alternative. In `Result<(Guid, AllocationLog), ValidationResult>`, the active success alternative supplies both the caller’s `Guid` response and an `AllocationLog` for server-side handling. If the active alternative is instead a `ValidationResult` error, Arc’s built-in handler records a validation failure; the success tuple is not processed. The [complete example below](#creating-custom-value-handlers) defines the command and its custom handler. See [caller examples](/arc/backend/csharp/commands/response-examples/#result-and-tuple-response) for consuming the result. ## Creating custom value handlers Here is a complete standalone set of application types. The command allocates an identifier; the custom handled value writes a diagnostic message. This sample does **not** create a durable business record, and diagnostic logging is not a durable audit trail. ```csharp using System; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Monads; using Microsoft.Extensions.Logging; public record AllocationLog(string Label); public class AllocationLogHandler(ILogger logger) : ICommandResponseValueHandler, ICommandResponseValueHandler { static readonly Action _allocated = LoggerMessage.Define( LogLevel.Information, new EventId(1, "IdentifierAllocated"), "Allocated identifier for {Label}"); public bool CanHandle(CommandContext context, object value) => value is AllocationLog; public Task Handle(CommandContext context, object value) { var entry = (AllocationLog)value; _allocated(logger, entry.Label, null); return Task.FromResult(CommandResult.Success(context.CorrelationId)); } } [Command] public record AllocateIdentifier(string Label) { public Result<(Guid, AllocationLog), ValidationResult> Handle() { if (string.IsNullOrWhiteSpace(Label)) { return ValidationResult.Error("A label is required", [nameof(Label)]); } return (Guid.NewGuid(), new AllocationLog(Label)); } } ``` Prerequisites in the host: * Configure Arc’s normal application type discovery and DI. Place the command and handler in a discovered application assembly referencing `Cratis.Arc.Core`. * Enable .NET logging and configure a logging provider to observe the message. `ILogger` must resolve. * Implement **both** handler interfaces as above. The non-generic interface is the runtime contract; the generic marker tells build-time tools that `AllocationLog` is handled server-side and is not the client response. * Do not install a handler for `Guid` if you want it to remain the caller’s response. Execution fragment in a caller with an injected `ICommandPipeline`: ```csharp var result = await pipeline.Execute(new AllocateIdentifier("invoice")); ``` On success, the log handler runs and `result.Response` contains the allocated `Guid`. On an empty label, Arc’s singular validation handler adds the rejection; `result.IsValid` is false and the typed response is `default(Guid)`. Implementing only `ICommandResponseValueHandler` does not supply a runtime handler. For dynamically accepted types, implement the runtime interface without an overly broad marker such as `object`, which would misdescribe the client contract. Typed declarations are discovered in referenced assemblies that reference `Cratis.Arc.Core`; the interfaces must have the actual Arc assembly identity, not just matching names. ## Declaring attributed values For an integration that consumes model-bound return values identified by an attribute, declare the supported shapes alongside `ICommandResponseValueHandler`. Both markers require `TAttribute` to derive from `Attribute`: * `ICommandResponseValueHandlerForAttributed` excludes values whose own type carries the attribute. It does not imply collection handling. * `ICommandResponseValueHandlerForAttributedEnumerable` excludes enumerables compatible with `IEnumerable` whose declared reference element type carries the attribute. It does not imply scalar handling. Rectangular arrays and value-type element collections remain responses. Direct attributes always qualify. Attributes declared on base types qualify only when their effective `AttributeUsage.Inherited` permits inheritance (the default is `true`). For example, the Chronicle single-event handler declares this marker in its class declaration (excerpt; the handler also implements the runtime `CanHandle` and `Handle` methods): ```csharp public class SingleEventCommandResponseValueHandler( IEventLog eventLog, IEventTypes eventTypes, IConcurrencyScopeStrategies concurrencyScopeStrategies) : ICommandResponseValueHandler, ICommandResponseValueHandlerForAttributed ``` These types come from `Cratis.Arc.Commands`, `Cratis.Chronicle.Events`, `Cratis.Chronicle.EventSequences`, and `Cratis.Chronicle.EventSequences.Concurrency`. Chronicle’s multiple-event handler declares only `ICommandResponseValueHandlerForAttributedEnumerable`. Neither handler declares `object` or `IEnumerable` as universally consumed: build-time tooling cannot infer event elements from an object collection. These markers describe supported static shapes; they do not register a runtime handler or change `CanHandle`. Keep declarations consistent with the values your integration actually consumes. Chronicle still needs registered event types and event-source routing, and its enumerable handler requires non-null registered elements. The static declaration cannot prove those value- and context-dependent conditions. As with the typed-value marker, tooling requires the genuine Arc.Core contracts and matches the attribute’s full name and defining assembly identity; a same-named attribute in another assembly does not qualify. ## Response object availability `CommandContext.Response` is phase-dependent, not a copy of the raw handler return: | Phase | What is available | | --- | --- | | Filters, scope `Begin`, and before handler execution | No response yet. | | Simple-value `CanHandle` check | The candidate has not yet been assigned as a response. | | Tuple classification | Earlier elements may already have supplied a response; do not assume it exists. `CanHandle` may run repeatedly. | | Tuple value handler `Handle` | The single unhandled response has been selected, if any. | | Scope `Complete` after successful response processing | The selected response, including an unhandled simple value. | Keep `CanHandle` side-effect free and prefer type checks that do not depend on a response being present. Use the context passed to the callback; do not assume a previously captured ambient context has been replaced with the later record instance. After all execution scopes complete, the pipeline clears the **result’s** response when execution is unsuccessful. A response observed inside a scope is not proof of final success; always check the returned `CommandResult.IsSuccess` before acting on it. ## Side effects and failures A custom handler performs real work after `Handle()` returns. Arc does not automatically compensate arbitrary work performed by these custom handlers or earlier direct service writes. For per-command application side effects, prefer [operations with optional compensation](/arc/backend/csharp/commands/operations/implementing/). Their recovery contract covers declared, started operations—not every write a custom handler or service might perform. For event-sourced applications only, [Chronicle transactional commands](/arc/backend/csharp/commands/transactional-commands/) describes the optional integration’s commit behavior. Continue with [response examples](/arc/backend/csharp/commands/response-examples/) for success, validation, and failure cases. # Transactional commands — optional Chronicle integration > Compatibility link to Chronicle's event-store-specific command transactions. Command transactions described on this route belong to the optional **`Cratis.Arc.Chronicle`** integration, not standalone Arc. Read [Chronicle transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/) for its commit, rollback, and immediate-append behavior. This compatibility page preserves the previous command-documentation route. Standalone Arc handlers can call application services and return ordinary values without Chronicle. Arc does not automatically begin a database transaction or roll back arbitrary service writes. For your own lifetime coordination, see [command execution scopes](/arc/backend/csharp/commands/command-execution-scopes/). Inline work does not require Chronicle Direct service calls remain supported. Prefer returned [command operations](/arc/backend/csharp/commands/operations/) for inline work chosen by a model-bound command. Arc executes them and manages eligible, explicitly supplied compensation—not automatic database rollback. Execution scopes remain the integration point for actual transaction coordination. ## Choosing an append style Continue to [Chronicle append styles](/arc/backend/csharp/chronicle/commands/transactional-commands/#choosing-an-append-style). ## How it works Continue to [Chronicle transaction completion](/arc/backend/csharp/chronicle/commands/transactional-commands/#completion). ## Nested commands and aggregates Continue to [nested commands and aggregate commit boundaries](/arc/backend/csharp/chronicle/commands/transactional-commands/#nested-commands-and-aggregates). ## Things to know Continue to [Chronicle transaction limitations](/arc/backend/csharp/chronicle/commands/transactional-commands/#things-to-know). # Command validation > Validate model-bound command input with property annotations or FluentValidation. When input is incomplete, return useful feedback before performing work. Arc’s model-bound pipeline runs validation filters before `Provide()` and `Handle()`. Blocking results stop execution and appear in `CommandResult.ValidationResults`; their members are reported as camelCase field paths, including results returned from `Provide()` or `Handle()` (`Name` becomes `name`, and `Items[0].Name` becomes `items[0].name`). Validation is not authorization, and a pre-flight check cannot guarantee that state remains unchanged. Read [severity filtering](/arc/backend/csharp/commands/validation-severity-filtering/) before relying on a rule as a non-overridable guard. ## Data annotations For positional records, explicitly target the generated **properties**. Arc’s command filter uses object-property validation, not constructor-parameter metadata. This complete command allocates a `Guid`; it does not persist a business record: ```csharp using System; using System.ComponentModel.DataAnnotations; using Cratis.Arc.Commands.ModelBound; [Command] public record AllocateLabeledIdentifier( [property: Required] string Label, [property: Range(1, 100)] int Copies) { public Guid Handle() => Guid.NewGuid(); } ``` An empty `Label` or an out-of-range `Copies` produces validation errors before `Handle()`, at the default severity threshold. Constructor annotations such as `[Required] string Label` do not provide the property metadata this filter reads. MVC record binding has different metadata rules; do not apply this advice mechanically to [controller-based commands](/arc/backend/csharp/commands/controller-based/). For command types with generated validation metadata, annotations on `ConceptAs` properties validate the underlying `.Value`. This includes `[Required]`, `[Range]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, and `[RegularExpression]`. A nullable concept passes absence-tolerant rules unless you add `[Required]`. Annotated model-bound query parameters use the same generated value readers. Types from other assemblies without generated metadata, and types skipped by the generator, keep the existing object-validation behavior: annotations receive the concept object rather than its value. In that fallback path, `[StringLength]` on a non-null concept throws instead of producing a validation result. Use a FluentValidation validator for those types. ## FluentValidation For richer rules, derive a validator from `CommandValidator`. Arc discovers it with the application’s types; no per-validator registration is needed under normal Arc discovery. This validator is an alternative to the annotations above, using the same command type: ```csharp using Cratis.Arc.Commands; using FluentValidation; public class AllocateLabeledIdentifierValidator : CommandValidator { public AllocateLabeledIdentifierValidator() { RuleFor(command => command.Label).NotEmpty().MaximumLength(100); RuleFor(command => command.Copies).InclusiveBetween(1, 100); } } ``` Remove duplicate annotations if you select FluentValidation for those same rules. The [proxy generator](/arc/backend/csharp/proxy-generation/validation/) extracts supported rules for early client feedback; server rules involving services or custom logic are not automatically equivalent client-side checks. ## Validator dependencies Validators may accept registered services through their constructors. Arc resolves these from the command scope, also used for `Provide()` and `Handle()` dependencies. This application-level example supplies its complete policy dependency and makes no database assumptions: ```csharp using Cratis.Arc.Commands; using FluentValidation; public record LabelPolicy(int MaximumLength); public class LabelPolicyValidator : CommandValidator { public LabelPolicyValidator(LabelPolicy policy) { RuleFor(command => command.Label).MaximumLength(policy.MaximumLength); } } ``` Host registration fragment, with `Microsoft.Extensions.DependencyInjection` imported: ```csharp builder.Services.AddSingleton(new LabelPolicy(80)); ``` Nullable dependency parameters may receive null when no value can be resolved. Non-nullable dependency parameters are required; resolution failure produces an exception outcome instead of silently constructing a validator with null. Use a nullable dependency when absence is a valid input to your rule, not to conceal a missing required service. A service that reads storage must actually implement that lookup. Arc.Core does not assume projected event state or supply persistence for arbitrary dependency types. For provider-specific current-state resolution, see [use current state in a command](/arc/scenarios/use-current-state-in-a-command/). [Chronicle read-model injection](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) is an optional integration, not the default validator dependency model. ## Choosing a rejection phase * Use a validator for input feedback before data provisioning and execution. * Use `Provide()` to fetch handler data after filters; it can return validation control values to short-circuit. See [provide data to a command](/arc/scenarios/provide-data-to-a-command/). * Use a singular `ValidationResult` alternative from `Handle()` for a decision made during handling. See [response value handlers](/arc/backend/csharp/commands/response-value-handlers/). A failure returned after a service write does not undo that write. * Use an [authorization filter](/arc/backend/csharp/commands/command-filters/#cross-cutting-authorization-by-namespace) for access control, not an overridable validation rule. For early feedback without invoking `Provide()` or `Handle()`, continue to [pre-flight command validation](/arc/backend/csharp/commands/command-validation/). # Validation severity filtering > Select warning thresholds without mistaking overridable validation for authorization. Some rules need acknowledgment rather than unconditional rejection. Model-bound Arc commands accept an `allowedSeverity` threshold for validation filters and `Provide()` control results. Results that the threshold permits are **removed**, not returned as advisory messages alongside success. Command filters continue past permitted failures, so an error from a later filter still rejects the command. ## Thresholds | `ValidationResultSeverity` | Numeric value | Meaning | | --- | --- | --- | | `Unknown` | 0 | Unclassified result | | `Information` | 1 | Informational feedback | | `Warning` | 2 | Acknowledgment-worthy feedback | | `Error` | 3 | Validation error | For commands without an explicit blocking policy and without declared read protection `On`, omitting the threshold keeps only `Error` results. With a caller threshold, only results whose severity is **greater than** the threshold remain. Thus: * `Information` blocks warnings and errors. * `Warning` allows warnings and blocks errors. * `Unknown` blocks information, warnings, and errors. * `Error` allows all currently defined severities, including errors. `ICommandPipeline.Execute` and `Validate` accept the threshold in both scope-free and scope-explicit forms. Model-bound HTTP endpoints read its integer value from `X-Allowed-Severity`. Controller actions use their own MVC validation path; do not assume this header configures MVC. ## Declare a blocking policy on a model-bound command Mark a model-bound command when warning or information failures must reject it even if the caller supplies a permissive threshold: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; [Command] [BlockOnValidationSeverity(ValidationResultSeverity.Information)] public record SubmitApplication(string Name) { public void Handle() { } } ``` The attribute names the **lowest severity that blocks**: `Information` blocks information, warnings, and errors; `Warning` blocks warnings and errors. `Unknown` results also block any command with the attribute, because an unclassified failure must not become a successful command. The rejected result retains its original message and severity. The declared policy is inherited by derived command types and applies to command filters, `Provide()`, `Validate`, and in-process execution as well as HTTP, when commands run through Arc’s built-in `CommandPipeline`. It does not apply to controller-action DTOs, which use the MVC validation path, or to custom `ICommandPipeline` implementations unless they implement the policy themselves. Generated TypeScript proxies carry the threshold for local validation and treat warnings as errors when the declared severity is `Warning` or stricter, but the server remains authoritative. The effective threshold is the stricter of the declared policy and the caller’s `allowedSeverity` or `X-Allowed-Severity`. A caller can demand *more* blocking, never less: passing `Error` or a larger integer does not permit a declared warning or information failure. This deliberately differs from the caller opt-out for controller actions in #21. Commands without the attribute retain caller-controlled filtering unless they declare read protection `On`. ## Read protection On supplies an Error floor A command declaring read protection `On` gets an `Error` blocking floor unless it declares `[BlockOnValidationSeverity(...)]`. `Error` and `Unknown` always block; callers can still allow `Warning` and `Information`, or demand a stricter threshold. The floor applies to filters, validators, `Provide()` control results, and `Validate` through the built-in pipeline. A validation error returned by `Handle()`, including `Result`, remains a rejection. Arc resolves the command’s inherited read-protection attribute before the attribute on the command’s own assembly. A command-level `[Unprotected]` opts out. The floor follows the **declared** mode: lowering `ReadProtection:MaximumMode` does not remove it. An explicit, possibly inherited, `[BlockOnValidationSeverity(...)]` takes precedence over the implicit floor. `Off` and `Measure` keep caller-controlled filtering. Without an explicit blocking policy, `X-Allowed-Severity: 3` can skip validator errors and let `Handle()` run. If you rely on validators under `Off`, declare `[BlockOnValidationSeverity(ValidationResultSeverity.Error)]`; this prevents severity filtering from dropping errors, but does not guard stale reads. ## Create a warning This complete command only allocates an identifier. Its validator warns about unusually long labels and rejects blank labels: ```csharp using System; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using FluentValidation; [Command] public record AllocateBatchIdentifier(string Label) { public Guid Handle() => Guid.NewGuid(); } public class AllocateBatchIdentifierValidator : CommandValidator { public AllocateBatchIdentifierValidator() { RuleFor(command => command.Label).NotEmpty(); RuleFor(command => command.Label) .MaximumLength(20) .WithMessage("A shorter label is easier to read") .WithSeverity(Severity.Warning); } } ``` Arc already maps FluentValidation’s `Error`, `Warning`, and `Info` to its own severities. Do not add a custom mapping filter. ## Confirm a warning These are two separate caller fragments, using the command above, `ICommandPipeline pipeline`, and `Cratis.Arc.Validation`. First submit with a threshold that **blocks** warnings: ```csharp var command = new AllocateBatchIdentifier("A deliberately long batch label"); var result = await pipeline.Execute( command, allowedSeverity: ValidationResultSeverity.Information); ``` The handler does not run and the warning remains in `result.ValidationResults`. Show it to the user. Only if the result is authorized, exception-free, has at least one validation result, and all remaining results are warnings should you offer a warning-only retry. Check `IsAuthorized`, `HasExceptions`, `ValidationResults.Any()`, and `ValidationResults.All(...)`; `All(...)` alone also accepts an empty collection. After the user explicitly confirms, make a **new** request with the unchanged command: ```csharp var confirmedResult = await pipeline.Execute( command, allowedSeverity: ValidationResultSeverity.Warning); ``` The rules run again. If nothing else fails, the command executes and returns its `Guid`. With the default threshold, the first request would already execute, so it cannot implement this confirmation flow. Confirmation is a UI convention, not server-enforced proof of consent. ## Scope and limitations Filtering applies after command filters and during `Provide()` argument resolution. It does **not** re-filter the singular validation value returned by `Handle()` through its response handler. A returned warning from `Handle()` therefore still makes that result invalid. Do not move a warning into a handler expecting this confirmation workflow to work unchanged. The built-in response handler recognizes only a singular Arc `ValidationResult`. A validation array returned by `Handle()` is response data, not a collection of validation failures. `Provide()` separately supports `IEnumerable` control values. Authorization filters run first; ordinary filters continue after non-blocking validation failures and stop on a blocking failure. Critical invariants still need enforcement at the operation/storage boundary; a validator is not a concurrency or integrity guarantee. ## Security considerations Caution The model-bound HTTP endpoint currently accepts any parsable integer in `X-Allowed-Severity`, including `3` and larger. For commands under `Off` or `Measure` without an explicit blocking policy, a caller can therefore remove Error-severity results from the filtered stages. Do not use validation severity for authentication, authorization, or non-overridable integrity enforcement. Return an actual authorization verdict from an [authorization filter](/arc/backend/csharp/commands/command-filters/#cross-cutting-authorization-by-namespace). `CommandResult.Unauthorized` is independent of severity filtering. Check permissions before performing work, and enforce atomic state invariants in the service or storage operation that owns the change. Continue with [pre-flight validation](/arc/backend/csharp/commands/command-validation/) or [frontend severity filtering](/arc/frontend/core/validation/severity-filtering/). # Configuring Arc > Configure Arc through ArcOptions from appsettings, environment variables, or code, for ASP.NET Core, Arc.Core, and Cratis hosts. Arc reads all of its settings from a single `ArcOptions` object. Wherever Arc runs — an ASP.NET Core web app, an Arc.Core console or worker, or the full Cratis stack — you configure it the same way: bind `ArcOptions` from configuration, then optionally override it in code. This page is the map: the hosting models, the three configuration mechanisms, and the full `ArcOptions` tree. ## The three hosting models | Host | Bootstrap | Activate | Use it for | | --- | --- | --- | --- | | **ASP.NET Core** | `WebApplication.CreateBuilder(args)` → `builder.AddCratisArc(...)` | `app.UseCratisArc()` → `app.Run()` | A web API or full-stack app. The listen URL comes from Kestrel / `launchSettings.json`. | | **Arc.Core** | `ArcApplication.CreateBuilder(args)` → `builder.AddCratisArc(...)` | `app.UseCratisArc()` → `await app.RunAsync()` | A console app or worker with no ASP.NET Core. The listen URL comes from `ArcOptions.Hosting.ApplicationUrl`. | | **Cratis stack** | `WebApplication.CreateBuilder(args)` → `builder.AddCratis(...)` | `app.UseCratis()` → `app.Run()` | Arc + Chronicle in one host — see the [Cratis package](/arc/backend/csharp/chronicle/cratis-package/). | `AddCratisArc` takes its arguments in this order: `configureOptions` (an `Action`), `configureBuilder` (an `Action` for optional integrations), and `configSectionPath`. Use the named `configureBuilder:` argument when you only want to add a builder feature. For example, this startup fragment adds the standalone MongoDB provider to an existing ASP.NET Core `WebApplicationBuilder`. First install `Cratis.Arc.MongoDB` and configure its connection and database as described in [MongoDB getting started](/arc/backend/csharp/mongodb/getting-started/). It does not require Chronicle. ```csharp using Cratis.Arc; builder.AddCratisArc(configureBuilder: arc => arc.WithMongoDB()); ``` For optional event sourcing, follow the host- and package-specific [Arc–Chronicle registration example](/arc/backend/csharp/chronicle/cratis-package/) instead of substituting an unqualified `WithChronicle()` call. For advanced wiring, `IServiceCollection.AddCratisArcCore()` registers Core services only; it is not equivalent to host bootstrap, configuration binding, request-context wiring, identity setup, endpoint mapping, or listener activation. `IHostBuilder.AddCratisArcCore(configureOptions: ...)` is a different overload. Prefer the host-specific builders above. ## Three ways to configure Configuration-bindable settings can be supplied three ways, layered in this order — later wins: 1. **`appsettings.json`** under the `Cratis:Arc` section. 2. **Environment variables** with the `Cratis__Arc__` prefix (.NET maps the `__` separator onto nested keys), for example `Cratis__Arc__GeneratedApis__RoutePrefix`. 3. **Code**, via the `configureOptions` callback — it runs after binding, so it overrides the file and the environment. Bind `ArcOptions` only through the host’s `AddCratisArc` / `AddCratis` registration (or `IHostBuilder.AddCratisArcCore`), not through an additional `services.Configure(section)` or `section.Bind(options)`. Arc’s binding preserves an unset `Introspection.RequireAuthentication`; the stock .NET binder can write its getter’s `false` value back even when the key is absent, making discovery anonymous in every environment (or failing validation when `Roles` is set). Assigning `RequireAuthentication` from its own getter, including a property-by-property copy, likewise counts as an explicit opt-out. Leave the property untouched to retain the environment default, or assign an intentional override in `configureOptions`. ```csharp builder.AddCratisArc(options => { options.GeneratedApis.RoutePrefix = "v1/api"; // overrides appsettings / env options.Introspection.RequireAuthentication = true; options.Introspection.Roles = "Administrator,Operator"; }); ``` ## The ArcOptions tree | Option | Type | Default | What it controls | | --- | --- | --- | --- | | `ExposeExceptionDetails` | `bool` | `RuntimeEnvironment.IsDevelopment` | Expose exception messages and stack traces in serialized command/query results only in Development by default. Full detail remains in server logs when responses are redacted. Keep disabled in public production environments. | | `CorrelationId.HttpHeader` | `string` | `X-Correlation-ID` | The header carrying the correlation ID. | | `Tenancy.ResolverType` | `TenantResolverType` | `Header` | How the tenant is resolved: `Header`, `Query`, `Claim`, `Subdomain`, `Development`, or `Fixed`. | | `Tenancy.HttpHeader` | `string` | `x-cratis-tenant-id` | The header used when `ResolverType` is `Header`, and the fallback header when it is `Subdomain`. | | `Tenancy.BaseDomain` | `string` | empty | Required for `Subdomain`: the application-owned base domain. Exactly one preceding DNS label selects a tenant; other hosts fall back to `HttpHeader`. Validation checks syntax, not domain ownership or tenant membership. | | `Tenancy.QueryParameter` | `string` | `tenantId` | The query parameter used when `ResolverType` is `Query`. | | `Tenancy.ClaimType` | `string` | `tenant_id` | The claim used when `ResolverType` is `Claim`. | | `Tenancy.FixedTenantId` | `string` | `development` | The tenant every request resolves to when `ResolverType` is `Fixed` or `Development`. | | `Tenancy.DevelopmentTenantId` | `string` | `development` | The same value under its original name — reading or writing either key sets both. Supply only one; if both are present the binder’s property order decides. | | `Calendar.TimeZone` | `string` | `UTC` | The IANA id of the application time zone, such as `Europe/Oslo`. Unknown, Windows-only and non-zero fixed-offset ids stop the host at startup; Arc never falls back to UTC. See [Application calendar](/arc/backend/csharp/application-calendar/). | | `Introspection.Enabled` | `bool` | `true` | Map both command and query catalog routes. Set to `false` to remove both routes without changing identity discovery. | | `Introspection.IdentityDiscovery` | `bool` | `true` | Map users, tenants and the identity-details schema. Set to `false` to remove these routes without changing catalogs or `/.cratis/me`. Set both switches to `false` to remove all discovery. | | `Introspection.RequireAuthentication` | `bool` | unset (getter returns `false`) | Access to the discovery routes (catalogs, `/.cratis/users`, `/.cratis/tenants`, `/.cratis/identity-details/schema`). Not set: anonymous in Development, authenticated elsewhere. `true`: authenticated everywhere; requires a default ASP.NET Core authentication scheme plus `AddAuthorization()`, or an Arc.Core authentication handler. `false`: anonymous everywhere. | | `Introspection.Roles` | `string?` | `null` | Comma-separated roles; any one grants access to the discovery routes. Implies authentication; cannot be combined with `RequireAuthentication: false`. No named policy option is provided. | | `Introspection.TrustForwardedIdentityHeaders` | `bool` | `false` | Obsolete. Setting it to `true` turns on `TrustForwardedIdentityHeaders` for the whole host. Use `TrustForwardedIdentityHeaders` instead. | | `TrustForwardedIdentityHeaders` | `bool` | `false` | Trust the unsigned `x-ms-client-principal*` identity headers on every request. Until it is `true`, Arc’s header handlers ignore them and requests stay anonymous. Use only behind an ingress that authenticates callers and strips client identity headers, such as EasyAuth or AuthProxy. See [Microsoft Identity](/arc/backend/csharp/asp-net-core/microsoft-identity/#trusting-the-forwarded-headers). | | `GeneratedApis.RoutePrefix` | `string` | `api` | Base prefix for generated command and query routes. | | `GeneratedApis.SegmentsToSkipForRoute` | `int` | `0` | Namespace segments to drop when building a route. | | `GeneratedApis.IncludeCommandNameInRoute` | `bool` | `true` | Append the command name as the last route segment. | | `GeneratedApis.IncludeQueryNameInRoute` | `bool` | `true` | Append the query name as the last route segment. | | `GeneratedApis.EnableQueryHttpMethod` | `bool` | `true` | Expose generated queries over HTTP `QUERY` with JSON arguments in addition to `GET`. Disable when infrastructure rejects that method. | | `Query.KeepAliveInterval` | `TimeSpan` | `00:00:30` | Idle keep-alive cadence for observable query hub connections; zero or negative disables keep-alive. | | `IdentityDetailsProvider` | `Type?` | `null` (auto-discovered) | The identity details provider type. | | `Hosting.ApplicationUrl` | `string` | `http://+:5001/` | The listen URL — **Arc.Core only** (ignored under ASP.NET Core). | | `JsonSerializerOptions` | `JsonSerializerOptions` | Arc defaults | Generated Arc endpoints use these options; manual serialization must opt in. MVC receives only the naming policy and converters, not null/number handling or other settings. Configure in code only; see the [MVC serialization boundary](/arc/backend/csharp/asp-net-core/configuration/#json-serialization). Add source-generated metadata with `AddJsonTypeInfoResolver`; see [Adding Source-Generated Metadata](/arc/backend/csharp/asp-net-core/configuration/#adding-source-generated-metadata). | For example, the equivalent `appsettings.json` keys are: ```json { "Cratis": { "Arc": { "Introspection": { "Enabled": true, "RequireAuthentication": true, "Roles": "Administrator,Operator" } } } } ``` `RequireAuthentication` and `Roles` govern mapped discovery endpoints: the command and query catalogs, users, tenants, and the identity-details schema. `Enabled` controls the catalogs; `IdentityDiscovery` controls users, tenants and the schema. Set both to `false` to skip discovery mapping, authentication enforcement checks and exposure warnings. Valid access settings then have no effect, but role configuration is still validated. Outside Development, hosts without authentication leave enabled discovery endpoints unmapped by default and warn once per host; explicitly requiring authentication on such a host fails startup. See [introspection production access](/arc/backend/csharp/introspection/#production-access) for the security boundary and startup validation. Route generation (`GeneratedApis`) and JSON serialization have worked examples on the [ASP.NET Core configuration](/arc/backend/csharp/asp-net-core/configuration/) page; `Query.KeepAliveInterval` is covered with the [observable query demultiplexer](/arc/backend/csharp/queries/observable-query-demultiplexer/). ## Adding features with the builder The `configureBuilder` callback exposes `IArcBuilder`, which is where Arc’s pluggable backends attach: * [Arc–Chronicle registration](/arc/backend/csharp/chronicle/cratis-package/) — optional event sourcing; choose the documented host-specific overload and packages. * `arc.WithMongoDB()` — MongoDB read models. See [MongoDB](/arc/backend/csharp/mongodb/). * `arc.WithEntityFrameworkCore()` — relational read models. See [Entity Framework](/arc/backend/csharp/entity-framework/). * `arc.WithoutControllers()` — leave MVC out. See [ASP.NET Core configuration](/arc/backend/csharp/asp-net-core/configuration/#turning-controllers-off). ## Identity and authentication The header/query/subdomain resolvers select a tenant; they do not authorize membership. See [tenant resolution](/arc/backend/csharp/tenancy/resolvers/) before accepting caller-selected tenant IDs. Arc resolves an identity details provider automatically by type discovery. Set `ArcOptions.IdentityDetailsProvider` to pin a specific type, or register one explicitly: ```csharp builder.Services.AddIdentityProvider(); ``` For authenticating requests, see [Authentication](/arc/backend/csharp/core/authentication/). When you use the [Cratis package](/arc/backend/csharp/chronicle/cratis-package/), Microsoft Identity Platform authentication is wired for you. ## Service provider validation A singleton that takes a scoped dependency in its constructor holds that one instance forever — the classic captive dependency. In a multi-tenant application it is the difference between “the right tenant’s data” and “whichever tenant happened to be first”, and nothing about it fails loudly. .NET has a detector for exactly this: `ServiceProviderOptions.ValidateScopes`, which the host turns on in Development so the capture throws the moment you resolve it. Arc keeps that detector on. Every host Arc supports settles two `ServiceProviderOptions` fields for you: | Option | Value Arc applies | Why | | --- | --- | --- | | `ValidateScopes` | `builder.Environment.IsDevelopment()` | The host’s own default, restated so it survives. On in Development, off everywhere else. | | `ValidateOnBuild` | `false` | Arc supplies registrations contextually — `IHostApplicationBuilder`, the type a convention binding is for, values only an executing command or an in-flight request can hand over. Eager validation constructs every registration up front and can resolve none of them, so leaving it on fails `Build()` outright. | The reason both fields have to be stated together is that `UseDefaultServiceProvider` and `ConfigureContainer` each start from a brand new options object — setting one field discards every other value the host had already applied. Turning `ValidateOnBuild` off without restating `ValidateScopes` is what silently took the captive-dependency check with it. ### Overriding it You own your container. State your own choice and it wins — with one ordering rule that differs by host: ```csharp // WebApplicationBuilder (ASP.NET Core): call it AFTER AddCratisArc. builder.AddCratisArc(); builder.Host.UseDefaultServiceProvider(options => { options.ValidateScopes = true; // on in every environment, not just Development options.ValidateOnBuild = false; // keep this off — see the table above }); ``` For an `IHostBuilder`, call `UseDefaultServiceProvider` directly on the builder after its Arc registration (there is no `builder.Host` property). Arc calls `UseDefaultServiceProvider` itself on these hosts, and the last call wins, so a call placed *before* `AddCratisArc` is discarded. Arc.Core has no such ordering rule. `ArcApplicationBuilder` applies its defaults while it is being constructed, so a `ConfigureContainer` call — your own factory, Autofac, Lamar — replaces them whether you make it before or after `AddCratisArc`: ```csharp var builder = ArcApplication.CreateBuilder(args); builder.ConfigureContainer(new MyServiceProviderFactory()); // before or after — either wins builder.AddCratisArc(); ``` Caution `ValidateOnBuild = true` fails `Build()` on any Arc application. The failure is an `AggregateException` naming the registrations Arc supplies contextually, and it is not a defect in your wiring — leave the flag off. ### When you get no validation at all `ValidateScopes` follows `IsDevelopment()`, which is an exact match on the environment name `Development`. A host running under a custom name — `Local`, `Dev`, `Staging` — is *not* Development by that rule, so it gets no scope validation, exactly as a bare .NET host would. If you want the check there, ask for it explicitly with the override above. ## A note on CORS CORS is **not** an Arc option. In an ASP.NET Core host, configure it with standard ASP.NET Core (`builder.Services.AddCors(...)` and `app.UseCors(...)`). Arc neither wraps nor replaces it. These ASP.NET extensions do not apply to `ArcApplication`; configure cross-origin handling at your trusted ingress for that host. Generated endpoints accept the [HTTP QUERY method](/arc/backend/csharp/queries/using-the-http-query-method/) in addition to GET by default (`GeneratedApis.EnableQueryHttpMethod = true`). If clients use it across origins, include `QUERY` in your allowed methods (`policy.WithMethods("GET", "POST", "QUERY")`) — it is not a simple method, so cross-origin calls preflight. This is a method-allowlist consideration, not a substitute for configuring allowed origins, headers, and credentials. A client that uses GET does not need QUERY in that allowlist. ## Where to go next * [ASP.NET Core configuration](/arc/backend/csharp/asp-net-core/configuration/) — route-generation examples and JSON serialization in depth. * [Arc.Core getting started](/arc/backend/csharp/core/getting-started/) — the console and worker host end to end. * [Tenancy](/arc/backend/csharp/tenancy/configuration/) — configure how the tenant is resolved. * [Identity](/arc/backend/csharp/identity/) — the identity system and providers. * [Application calendar](/arc/backend/csharp/application-calendar/) — the application time zone and clock-boundary re-evaluation. * [Proxy Generation](/arc/backend/csharp/proxy-generation/configuration/) — the build-time `CratisProxies*` settings that must match `GeneratedApis`. # Arc.Core > Standalone Arc pipelines and lightweight HTTP hosting without ASP.NET Core or an event store. Use `Cratis.Arc.Core` when you want commands, queries, validation, authorization, identity details, and tenant context without the ASP.NET Core host. A console application or worker can expose HTTP endpoints through `ArcApplication` and .NET’s `HttpListener`. ## Start with a working service The [getting-started checkpoint](/arc/backend/csharp/core/getting-started/) runs a command and query using only Core. Read the [host comparison](/arc/backend/csharp/core/overview/) to decide whether Core or the `Cratis.Arc` ASP.NET integration fits your application. Performance and Native AOT compatibility require testing your actual dependencies and publish configuration; they are not guaranteed by choosing Core. ## Topics * [Overview](/arc/backend/csharp/core/overview/) — capabilities, boundaries, and host choice. * [Getting started](/arc/backend/csharp/core/getting-started/) — register, activate, and run. * [Endpoint mapping](/arc/backend/csharp/core/endpoint-mapping/) — manual GET/POST endpoints. * [Static files](/arc/backend/csharp/core/static-files/) — public assets and SPA fallback. * [Authentication](/arc/backend/csharp/core/authentication/) — trusted principals and forwarded-header prerequisites. * [Authorization](/arc/backend/csharp/core/authorization/) — pipeline authentication and role checks. * [OpenAPI](/arc/backend/csharp/core/openapi/) — built-in lightweight route documentation. * [Invariant culture](/arc/backend/csharp/core/invariant-culture/) — default culture configuration. ## Shared features and optional integrations [Commands](/arc/backend/csharp/commands/), [queries](/arc/backend/csharp/queries/), [identity](/arc/backend/csharp/identity/), and [tenancy](/arc/backend/csharp/tenancy/) belong to standalone Arc. Review [configuration](/arc/backend/csharp/configuration/) and [discovery access defaults](/arc/backend/csharp/introspection/) before deployment. Add [MongoDB](/arc/backend/csharp/mongodb/) or [EF Core](/arc/backend/csharp/entity-framework/) for persistence without requiring event sourcing. Add [Chronicle](/arc/backend/csharp/chronicle/) only when you want its event log, projections, and integration behavior. These integrations have their own packages and setup requirements. # Authentication > Authenticate lightweight Arc requests without confusing credential validation with forwarded identity headers. Before a command can check a role, something must establish who sent the request. In the lightweight `Cratis.Arc.Core` host, `IAuthenticationHandler` implementations produce a `ClaimsPrincipal`. ASP.NET Core applications instead configure their host’s authentication; see [Microsoft Identity integration](/arc/backend/csharp/asp-net-core/microsoft-identity/). ## Authentication flow Arc discovers `IAuthenticationHandler` implementations through `IInstancesOf` and tries them sequentially: 1. `AuthenticationResult.Anonymous` means this handler does not apply; try the next one. 2. `AuthenticationResult.Succeeded(principal)` stops the sequence and supplies the request principal. 3. `AuthenticationResult.Failed(reason)` stops the sequence without authenticating. Do not depend on a particular discovery order or assume individual DI registrations define that order. If mechanisms overlap, make their applicability unambiguous. An earlier success means later handlers cannot veto it. ``` Request Applicable authentication handler Validated principal Arc authorization and validation Command or query logic ``` ## Implementing an authentication handler This **illustrative integration fragment** defines the Arc adapter and an application-owned validator contract, not a runnable JWT implementation. Register a real `ITokenValidator` implementation before using it. The validator must verify signature, trusted issuer, audience, lifetime, and applicable revocation requirements using your identity library. Decoding a token is not validation. ```csharp using System.Security.Claims; using Cratis.Arc.Authentication; using Cratis.Arc.Http; public interface ITokenValidator { Task Validate(string token); } public class BearerTokenAuthenticationHandler(ITokenValidator validator) : IAuthenticationHandler { public async Task HandleAuthentication(IHttpRequestContext context) { if (!context.Headers.TryGetValue("Authorization", out var header) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { return AuthenticationResult.Anonymous; } var token = header["Bearer ".Length..].Trim(); if (token.Length == 0) { return AuthenticationResult.Failed("Invalid credentials"); } try { var principal = await validator.Validate(token); return principal?.Identity?.IsAuthenticated == true ? AuthenticationResult.Succeeded(principal) : AuthenticationResult.Failed("Invalid credentials"); } catch (Exception) { return AuthenticationResult.Failed("Authentication unavailable"); } } } ``` Missing bearer credentials let another mechanism try. Empty, invalid, or unverifiable bearer credentials never become a successful principal. Log operational failures server-side without recording tokens or disclosing validation internals to clients. API keys and passwords likewise need a real credential store and validator; never compare against sample hardcoded secrets or put credentials into claims. ## Microsoft Identity Platform (Azure) Core already supplies `Cratis.Arc.Identity.MicrosoftIdentityPlatformAuthenticationHandler`, discovered with the other authentication handlers. Do not implement a second EasyAuth parser. It reads these forwarded headers: | Header | Purpose | | --- | --- | | `x-ms-client-principal-id` | User ID | | `x-ms-client-principal-name` | Display name | | `x-ms-client-principal` | Base64 JSON principal containing roles and claims | The handler requires all three headers, rejects an invalid principal representation, replaces forwarded subject/identifier claims, and takes the reserved `MicrosoftIdentityPlatformClaims.IdentityProvider` claim from the payload’s `identityProvider` field. This establishes single provenance **within the payload**, not authenticity of the sender. Caution These headers are not signed credentials. Deploy this mechanism only behind trusted ingress that authenticates callers, strips caller-supplied identity headers, writes its own values, and prevents direct access to the backend. A custom `X-User-ID` or `X-User-Role` header needs the same protections. Adding a bearer validator does not make a separately accepted forwarded-header mechanism safe. The handler ignores the forwarded headers, and returns `AuthenticationResult.Anonymous`, unless the host opts in with `Cratis:Arc:TrustForwardedIdentityHeaders` (`ArcOptions.TrustForwardedIdentityHeaders`). The setting defaults to `false` and applies to every request, including protected [introspection catalogs](/arc/backend/csharp/introspection/). Turn it on only when the host runs behind such an ingress: ```json { "Cratis": { "Arc": { "TrustForwardedIdentityHeaders": true } } } ``` The first request that carries the headers while they are not trusted logs a warning that names this setting. Arc cannot detect a custom handler that reads forwarded headers. If yours does, check the same option through `IOptions` and return `AuthenticationResult.Anonymous` when `TrustForwardedIdentityHeaders` is `false`. In the ASP.NET Core package, the corresponding registration is `builder.Services.AddMicrosoftIdentityPlatformIdentityAuthentication()`. It is not the Core registration API. See [Microsoft Identity Platform](/arc/backend/csharp/asp-net-core/microsoft-identity/) for that host’s setup and local-development principals. ## Endpoint enforcement and limits The lightweight authentication middleware installs the successful principal on `IHttpRequestContext.User`. With handlers present, an endpoint not explicitly allowing anonymous access returns HTTP 401 if authentication does not succeed. An endpoint with `AllowAnonymous = true` proceeds even when credentials fail. If **no handlers** are available, the middleware proceeds without authenticating, except for an endpoint whose metadata sets `RequireAuthentication = true`: for that endpoint it throws `AuthenticationRequiredWithoutHandlers` (`Cratis.Arc.Http`) instead of letting the request through. Metadata without `RequireAuthentication` is not a fail-closed protection in that configuration. By default, handlers still run on an `AllowAnonymous` endpoint because establishing `context.User` there is often wanted - Arc’s current-caller identity and observable query demultiplexer endpoints are anonymous yet can still depend on the principal being set for a signed-in caller. Discovery endpoints are anonymous by default only in Development, or when `ArcOptions.Introspection.RequireAuthentication` is explicitly `false`; elsewhere they require authentication by default and remain unmapped with a warning if the host cannot authenticate. For a handler that should do no work on anonymous endpoints, implement `AppliesToAnonymousEndpoints => false`. Arc skips that handler entirely on those endpoints, including its rejection logging, and continues to the next handler. The default interface implementation returns `true`, so existing implementations need no change. Opting out never skips a protected endpoint or a request without endpoint metadata. In an `IAuthenticationHandler` implementation: ```csharp public bool AppliesToAnonymousEndpoints => false; ``` Keep the default of `true` when a handler should still establish a principal on an anonymous endpoint. If it only needs to behave differently there, check `context.AllowsAnonymous()` inside `HandleAuthentication` instead. The middleware sets the matched endpoint’s metadata before calling authentication; `AllowsAnonymous()` (from `HttpRequestContextEndpointExtensions`) is `true` only when that metadata declares `AllowAnonymous = true`. This opt-out belongs to the Arc.Core listener’s `IAuthentication` pipeline. ASP.NET Core hosting uses ASP.NET Core authentication schemes and endpoint authorization; configure anonymous access through that host’s authentication and authorization setup instead. Arc command/query authorization is a separate pipeline check. Read the current principal through `ICurrentPrincipalAccessor` from `Cratis.Arc.Authorization`, not the identity details sent to the frontend. See [Authorization](/arc/backend/csharp/core/authorization/) for roles, result status, and direct-call boundaries. ## Testing authentication handlers Before exposing the service, exercise missing credentials, malformed input, arbitrary tokens, expired/wrong-issuer/wrong-audience tokens, a genuinely valid token, forged forwarded headers, and backend access bypassing ingress. Also test endpoints with and without anonymous metadata and every accepted authentication mechanism. A valid-token-only test cannot establish a fail-closed boundary. ## Next steps * [Authorization](/arc/backend/csharp/core/authorization/) — apply authentication and role requirements. * [Identity](/arc/backend/csharp/identity/) — supply frontend identity details without treating them as credentials. * [Endpoint mapping](/arc/backend/csharp/core/endpoint-mapping/) — understand manual endpoint responsibilities. # Authorization > Protect Arc pipelines with authentication, roles, and named asynchronous policies. An authenticated caller is not necessarily allowed to perform every operation. Arc’s authorization filters check the current principal before command or query logic runs. These checks belong to `Cratis.Arc.Core`; they do not require ASP.NET Core, a database, or Chronicle. ## Authorization attributes Use attributes from `Cratis.Arc.Authorization`: | Attribute | Arc pipeline behavior | | --- | --- | | `[Authorize]` | Requires an authenticated principal. | | `[Authorize(Roles = "Admin,Manager")]` | Requires authentication and at least one listed role. | | `[Roles("Admin", "Manager")]` | The same OR-role check; derives from Arc’s `AuthorizeAttribute`. | | `[Authorize(Policy = "ActiveSubscription")]` | Requires authentication by default and the named policy to succeed asynchronously. An explicitly opted-in policy may evaluate guests. | | `[AllowAnonymous]` | Explicitly allows anonymous access. | | No authorization attribute | No pipeline authentication or role requirement unless a fallback evaluator supplies one. HTTP endpoints and hub subscriptions also apply the host’s default authorization. | Every authorization attribute on a declaration applies: stacked roles and policies are combined with AND. Register each policy before starting the host; an unknown or duplicate policy fails startup, and an unresolved policy at runtime never grants access. The standalone Arc host cannot authenticate named schemes: `AuthenticationSchemes` fails startup (and [ARC0021](/arc/backend/csharp/code-analysis/#arc0021-unevaluated-authorization-settings) flags it). Use [ASP.NET Core integration](/arc/backend/csharp/asp-net-core/authorization/) for actual scheme authentication. ## Protect a command This complete type fragment uses only Arc and .NET; add it to the [Core host](/arc/backend/csharp/core/getting-started/). Its return value is an ordinary response, not an event append: ```csharp using Cratis.Arc.Authorization; using Cratis.Arc.Commands.ModelBound; [Authorize] [Command] public record Greet(string Name) { public string Handle() => $"Hello, {Name}!"; } ``` Through the command pipeline, anonymous callers cannot reach `Handle()`. For role protection, replace `[Authorize]` with `[Roles("Admin", "Manager")]`. ## Register a named policy A policy resolves from the command or query’s executing service scope, so it can depend on scoped collaborators. This example uses a trusted claim established by your host’s authentication mechanism: ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Authorization; public class ActiveSubscription : IAuthorizationPolicy { public ValueTask IsAuthorized(AuthorizationPolicyContext context, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return ValueTask.FromResult(context.Principal.HasClaim("subscription", "active")); } } ``` In the host builder, before `Build()`: ```csharp using Cratis.Arc.Authorization; builder.Services.AddArcAuthorizationPolicy("ActiveSubscription"); ``` Apply `[Authorize(Policy = "ActiveSubscription")]` to a model-bound command or read model. `AuthorizationPolicyContext.Target` names its type or query method; `Resource` is the executing `CommandContext` or `QueryContext`. `ReceivedAt` is the same `DateTimeOffset` as `Resource.ReceivedAt` for an Arc pipeline operation. It remains fixed when policy evaluation is delayed or repeated, including when a selected scheme moves execution into another service scope. Arc captures it at model-bound transport dispatch before binding and authentication preparation, at direct pipeline entry, or per hub subscribe operation (not per connection). It is Arc receipt time, not network arrival time or the time before application middleware. MVC action filters establish their contexts after MVC binding. By default, an authenticated principal is required even when a policy itself permits anonymous callers. To evaluate an unauthenticated caller, explicitly register a guest-aware Arc policy with `builder.Services.AddArcAuthorizationPolicy("PublicOrMember", evaluatesAnonymous: true)`; the policy must decide whether the guest is allowed. The policy receives an empty unauthenticated `ClaimsPrincipal` for guests. For ASP.NET Core-registered policies, register the policy as usual and also call `builder.Services.AddArcAnonymousAspNetAuthorizationPolicy("PublicOrMember")`; the policy must not require an authenticated user. Both Arc and Microsoft’s `[Authorize(Policy = "PublicOrMember")]` work identically on the ASP.NET Core host. Every stacked requirement must be an opted-in policy without roles or authentication schemes to evaluate a guest; roles and schemes always require authentication. Policy checks are awaited before `Provide()`, `Handle()`, or a query method runs. Command context-value providers and execution-scope `Begin` run before the policy verdict so filters retain their established ordering; they may run for a caller ultimately denied by the policy. A named scheme, when supported by the ASP.NET Core host, is authenticated and selected before those hooks, but the hooks must not treat selection as authorization or perform irreversible business effects. The old synchronous `IAuthorizationEvaluator.IsAuthorized` entry points reject policy-bearing declarations; use the command/query pipelines instead. Mapped model-bound HTTP endpoints whose effective declaration consists entirely of guest policies let unauthenticated callers reach the Arc pipeline on both hosts. This includes command validation and query GET/QUERY endpoints. The lightweight host does not reject these callers merely because authentication handlers are registered; the ASP.NET Core host emits anonymous endpoint metadata so an authentication-requiring `FallbackPolicy` does not reject them before Arc evaluates their policies. This metadata is not an authorization verdict: a policy denial still returns **403 Forbidden** and skips command/query logic. Do not add `[AllowAnonymous]` alongside the policy; that declaration remains ambiguous. Endpoints with an ordinary policy, a mixed guest/ordinary declaration, roles, authentication schemes, or no guest policy retain their existing HTTP authentication behavior. Query method declarations still replace type declarations, including when the method opts into a guest policy. For a time-sensitive admission rule, use the captured receipt instead of reading the clock again after an asynchronous policy lookup. This complete policy type fragment assumes an Arc host with policies registered as above; the UTC window is illustrative, not a substitute for your application’s authorization rules: ```csharp using System; using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Authorization; public class WeekdayAdmission : IAuthorizationPolicy { public ValueTask IsAuthorized(AuthorizationPolicyContext context, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (context.ReceivedAt == default) return ValueTask.FromResult(false); var weekday = context.ReceivedAt.UtcDateTime.DayOfWeek; return ValueTask.FromResult(weekday != DayOfWeek.Saturday && weekday != DayOfWeek.Sunday); } } ``` Register this type with `builder.Services.AddArcAuthorizationPolicy("WeekdayAdmission")` before `Build()`, then apply `[Authorize(Policy = "WeekdayAdmission")]`. The rule uses Arc receipt time even if evaluating the policy starts later and denies a missing receipt. Set the `TimeProvider` in tests when asserting a specific receipt date. For policy- or scheme-protected declarations, Arc checks policy-input and ambient execution identity before and after each policy, including construction, and denies access on detected change before another policy runs. It also rechecks the execution principal and effective requirements after policy and legacy evaluator callbacks and before invoking a command handler or query action. A model-bound or controller-based query checks immediately before calling its method. A changed declaration or missing asynchronous verdict denies access. Roles-only and plain `[Authorize]` declarations are decided synchronously, without an asynchronous verdict to recheck. Identity continuity compares captured standard identity content: claims (including claim properties), authentication type, name and role claim types, label, and the captured actor chain. It checks references for custom principal/identity types and opaque bootstrap contexts but cannot fingerprint mutable state hidden behind custom behavior or inside opaque objects. These checks do not sandbox trusted application code: a policy can mutate and restore identity within one callback (including ASP.NET Core handlers inside one `AuthorizeAsync` call). They do not detect changes in an external permission store after admission, prevent arbitrary work or side effects inside callbacks, bind separately writable ASP.NET Core and Arc principal views or pre-verdict tenant/service bindings, control raw contexts or services retained outside Arc’s supported execution scope, or cover identity-bound services constructed outside Arc before pipeline entry. Identity continuity is not ongoing permission revocation: observable emissions need their own guards, and deferred query enumeration and observable emissions are not rechecked by this policy verdict. See [decision 0006](https://github.com/Cratis/Arc/blob/main/decisions/0006-opt-in-anonymous-authorization-policy-evaluation.md) for the trust boundary. ## Authorization when a command body cannot bind Model-bound execute and `/validate` endpoints report binding errors only after authentication, role, scheme and fallback checks. A denied caller receives 403 instead of a malformed-request result. No command context providers, execution scopes or authorization command filters run without an instance. The ASP.NET host can evaluate a named policy with a null resource only when every requirement in the complete policy set is resource-free. The built-in authentication, role, claim and name requirements qualify; `AssertionRequirement` does not. A custom ASP.NET requirement can implement `Cratis.Arc.Authorization.IResourceFreeRequirement` (from `Cratis.Arc`) to promise that all its handlers read only the user, not the resource. Do not mark requirements that compare claims with command properties or otherwise depend on the command context. If any policy has a resource-dependent requirement, none of the policy set is evaluated for that unbindable body. The binding failure remains Validation after authentication and roles pass, even for a caller whose bound command would be denied by policy. Native Core `IAuthorizationPolicy` implementations have no requirement-level opt-in and remain skipped on this path. They continue to receive a bound resource on normal executions. See [command input binding](/arc/backend/csharp/commands/input-binding/) for the exact error contract. ## Protect a query This complete type fragment requires either role for every query unless a method specifies a different requirement: ```csharp using Cratis.Arc.Authorization; using Cratis.Arc.Queries.ModelBound; [Roles("Admin", "Auditor")] [ReadModel] public record ServiceStatus(string State) { public static ServiceStatus Internal() => new("Running"); [AllowAnonymous] public static ServiceStatus Public() => new("Available"); } ``` Method authorization **replaces** the type declaration; it does not combine with it. For example, on a read model with type-level `[Roles("Admin")]`, a method-level `[Authorize(Policy = "PublicOrMember")]` registered with `evaluatesAnonymous: true` is reachable by guests when that policy allows them. The type-level Admin role no longer protects that method. Arc rejects conflicting `[Authorize]` and `[AllowAnonymous]` on the same target with `AmbiguousAuthorizationLevel`; do not combine them. ## Set baseline requirements without overriding anonymous access Implement `IFallbackAuthorizationEvaluator` when you want an authentication or role requirement for commands and queries that have no explicit authorization declaration. Arc discovers implementations by convention; do not register one manually. This example requires the `Member` role by default in an existing Arc host: ```csharp using System; using System.Collections.Generic; using System.Reflection; using Cratis.Arc.Authorization; public class MembersByDefault : IFallbackAuthorizationEvaluator { public IEnumerable GetAuthorizationRequirements(Type type) => [AuthorizationRequirement.FromRoles("Member")]; public IEnumerable GetAuthorizationRequirements(MethodInfo method) => []; } ``` An unannotated command type and an unannotated query now require the `Member` role. Commands and queries whose authorization target is a type consult only `GetAuthorizationRequirements(Type)`; `GetAuthorizationRequirements(MethodInfo)` applies only to query methods. Put a baseline intended for all commands in the `Type` overload. For query methods, Arc first checks the method and its declaring type for explicit declarations; only if neither has one does it consult the fallback evaluators. The type and method fallback requirements then **all** apply, including requirements supplied by different evaluators. `[AllowAnonymous]` on a method permits anonymous access even when its type has a baseline; a method-level `[Authorize]` or type-level `[Authorize]` replaces the baseline. Controller-based queries reached through Arc’s observable hub path use the query pipeline and can receive this baseline. Direct HTTP GET and WebSocket requests to controller-based queries are MVC actions, not covered by `IFallbackAuthorizationEvaluator`; configure ASP.NET Core’s [`FallbackPolicy`](/arc/backend/csharp/asp-net-core/authorization/#protecting-all-endpoints-by-default) to protect those actions without authorization metadata, including controller-based commands. An unauthenticated or wrong-role caller is denied before an Arc command or query runs. Use `IAuthorizationAttributeEvaluator` only to report **explicit** declarations from another attribute family. Its requirements still conflict with `[AllowAnonymous]` on the same member and raise `AmbiguousAuthorizationLevel`; they do not turn into a baseline automatically. Baseline evaluators return requirements, not authorization verdicts or cached principal-specific results. For resource-dependent checks, use a named policy or a pipeline filter instead. ## Host defaults on query hub subscriptions The hub keeps its transport routes anonymous, but checks the host’s default authorization before each query runs. On the standalone Arc Core host, registering authentication handlers requires an authenticated caller for queries that are not anonymous. On ASP.NET Core, the hub evaluates the host’s `FallbackPolicy`. Arc’s `[AllowAnonymous]` and fully opted-in guest declarations bypass this host check, just as they do on direct mapped endpoints; the query pipeline still evaluates guest policies and its other filters. A denied or failed host check never runs query logic and sends `Unauthorized`; SSE subscribe also returns 401. This host check is separate from `IFallbackAuthorizationEvaluator`: it protects hub admission, not server-side pipeline invocations. Non-streaming query results sent through the hub also pass [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/) before delivery. Direct HTTP non-observable queries retain their existing authorization and do not invoke those guards. ## The pipeline boundary ``` Mapped HTTP endpoint Command or query pipeline Server-side pipeline caller Authentication, roles, and async policies Handle or static query method Ordinary C# method call ``` Attributes do not intercept ordinary C# calls. `new Greet("World").Handle()` and `ServiceStatus.Internal()` bypass pipeline authorization, validation, filters, and result handling. Use mapped endpoints or [the command pipeline](/arc/backend/csharp/commands/command-pipeline/) / [query pipeline](/arc/backend/csharp/queries/query-pipeline/) when those guarantees are required. Server-side callers must also establish an appropriate principal through the supported execution scope; being in-process does not itself prove permission. A nested Core pipeline may use a different trusted server-side principal in a no-scheme execution scope, restoring the outer actor afterward. The restriction on changing a scheme-selected actor after an outer command has bound its unit of work applies to ASP.NET Core scheme selection, not to ordinary no-scheme `BeginScope` nesting. ## Working with claims Core supplies `ICurrentPrincipalAccessor.Current`, a nullable `ClaimsPrincipal` independent of transport. During HTTP requests it reads the request principal; outside HTTP it can read the principal established by a server-side execution scope. This complete type fragment returns a claim as query data: ```csharp using System.Security.Claims; using Cratis.Arc.Authorization; using Cratis.Arc.Queries.ModelBound; [Authorize] [ReadModel] public record CurrentUser(string? Id) { public static CurrentUser Mine(ICurrentPrincipalAccessor principalAccessor) => new(principalAccessor.Current?.FindFirst(ClaimTypes.NameIdentifier)?.Value); } ``` Use `FindFirst(ClaimTypes.NameIdentifier)?.Value` or `IsInRole(...)` on this principal for application checks. No `IHttpContextAccessor` is required by the lightweight host. [Identity details](/arc/backend/csharp/identity/) are presentation data and do not add trusted claims to this principal. ## Custom authorization logic Authentication and roles are coarse-grained. Ownership, tenant membership, and resource-specific permissions still need application checks based on trusted claims and authoritative data. Put reusable permission checks into appropriate pipeline filters; a command filter rejecting access returns `CommandResult.Unauthorized(context.CorrelationId)`, not `CommandResult.Error(...)`. A handler can instead return a `ValidationResult.Error(...)` using a typed `Result` for a business rejection. That is **validation**, not an authorization result: callers receive validation errors rather than a forbidden result. Do not describe it as policy enforcement or assume it rolls back side effects already performed. Standalone Arc does not append events; event-return behavior belongs to the optional [Chronicle integration](/arc/backend/csharp/chronicle/commands/events/). ## Authorization results An Arc pipeline authorization rejection sets `IsAuthorized = false`; mapped HTTP results use **403 Forbidden**. Lightweight authentication middleware may reject earlier with **401 Unauthorized**. ASP.NET Core authentication/authorization middleware has its own challenge/forbid behavior. Do not infer which layer ran solely from an HTTP status. ## Testing authorization Use [command scenarios](/arc/backend/csharp/testing/command-scenario/) to exercise the actual pipeline and assert `ShouldNotBeAuthorized()` or `ShouldBeAuthorized()`. Test anonymous, authenticated-without-role, and allowed-role principals. Add resource and tenant membership cases for custom rules. Test HTTP separately when relying on ingress or ASP.NET middleware; direct pipeline specs cannot prove those boundaries. ## Next steps * [Authentication](/arc/backend/csharp/core/authentication/) — establish a trustworthy principal. * [ASP.NET Core authorization](/arc/backend/csharp/asp-net-core/authorization/) — separate host policies from Arc checks. * [Tenancy](/arc/backend/csharp/tenancy/) — distinguish tenant selection from permission and storage isolation. # Endpoint mapping > Map manual lightweight HTTP endpoints and understand their authentication and routing limits. Arc maps commands and queries automatically. For a health check or a small custom HTTP operation, you can map a handler directly. A manual handler does **not** enter the command/query pipelines unless you explicitly call them. ## Basic usage This runnable `Program.cs` checkpoint assumes a console project with `Cratis.Arc.Core` installed, as in [getting started](/arc/backend/csharp/core/getting-started/): ```csharp using Cratis.Arc; using Cratis.Arc.Http; var builder = ArcApplication.CreateBuilder(args); builder.AddCratisArc(options => options.Hosting.ApplicationUrl = "http://localhost:5000/"); var app = builder.Build(); app.UseCratisArc(); app.MapGet("/health", context => context.Write("OK"), new EndpointMetadata(Name: "Health", AllowAnonymous: true)); await app.RunAsync(); ``` `curl http://localhost:5000/health` returns `OK`. `UseCratisArc()` schedules listener startup; omitting it leaves this program without the listener activation required by `RunAsync()`. ## Method signatures Both extension methods return the application for chaining: | Method | Arguments | | --- | --- | | `MapGet` | `string pattern`, `Func handler`, optional `EndpointMetadata? metadata = null` | | `MapPost` | The same arguments | The current lightweight mapping matches literal paths, not route-template parameters. Use a query parameter for an ID rather than `/users/{id}`. These public helpers expose GET and POST; choose ASP.NET Core for advanced routing and its broader HTTP API surface. ## Working with IHttpRequestContext The following **mapping fragment** belongs before `RunAsync()` in the checkpoint: ```csharp app.MapGet("/lookup", async context => { if (!context.Query.TryGetValue("id", out var id) || string.IsNullOrWhiteSpace(id)) { context.SetStatusCode(400); await context.Write("An id query parameter is required."); return; } var result = new { Id = id }; context.SetResponseHeader("Cache-Control", "no-store"); await context.WriteResponseAsJson(result, result.GetType()); }, new EndpointMetadata(Name: "Lookup", AllowAnonymous: true)); ``` For `/lookup?id=123`, the result is `{"id":"123"}`. Other context members include `Headers`, `Cookies`, `Path`, `Method`, `User`, `RequestServices`, and `RequestAborted`. Read JSON with `ReadBodyAsJson(typeof(T))`; use the request scope’s service provider for dependencies. Treat headers, cookies, and query values as untrusted input. ## Endpoint metadata | Property | Purpose | | --- | --- | | `Name` | Endpoint identifier and OpenAPI operation ID | | `Summary` | Documentation text | | `Tags` | Documentation grouping | | `AllowAnonymous` | Whether lightweight authentication middleware lets an unauthenticated request proceed | With authentication handlers available, `AllowAnonymous: false` requires a successful authentication result. **With no handlers, current middleware proceeds without authentication.** Metadata is not a role policy and does not run Arc command/query authorization. See [authentication limits](/arc/backend/csharp/core/authentication/#endpoint-enforcement-and-limits). For private manual endpoints, also check the authenticated principal and application permission before accessing data, or invoke the appropriate authorized pipeline. For webhooks, verify the provider’s signature over the original payload using its supported library **before** processing it. Merely reading a signature header is not verification. ## Integration with OpenAPI In the checkpoint, add `using Cratis.Arc.OpenApi;` and call `app.MapOpenApi()` before `RunAsync()`. This exposes `/openapi.json`. The lightweight document describes routes and metadata, not complete body schemas, and is anonymously accessible by default. See [Core OpenAPI](/arc/backend/csharp/core/openapi/) for its limits and the separate ASP.NET implementation. ## See also * [Getting started](/arc/backend/csharp/core/getting-started/) — register and activate the host. * [Authentication](/arc/backend/csharp/core/authentication/) — establish trusted request principals. * [Authorization](/arc/backend/csharp/core/authorization/) — protect pipeline execution. * [Static files](/arc/backend/csharp/core/static-files/) — assets and SPA fallback. # Getting started with the lightweight host > Run standalone Arc commands and queries in a console project without ASP.NET Core or an event store. Let’s expose a greeting command and query from a console application. This checkpoint uses only `Cratis.Arc.Core`: no ASP.NET Core, MongoDB, EF Core, or Chronicle. A command can return ordinary response data; it does not need to append an event. ## Prerequisites and installation Use the latest .NET SDK and current Cratis packages. The SDK must support the compiler APIs used by Arc’s analyzers; that build requirement is separate from your application’s target framework. Create a console project and add the lightweight package: ```bash dotnet new console -n GreetingService cd GreetingService dotnet add package Cratis.Arc.Core ``` `Cratis.Arc` is the separate ASP.NET Core integration package. If you want Kestrel, MVC, or the ASP.NET middleware ecosystem, follow the [ASP.NET Core guide](/arc/backend/csharp/asp-net-core/) instead. Do not add `Cratis.Arc` to this project: with it referenced, `ArcApplication` stops at startup with `Unable to resolve service for type ... IActionDescriptorCollectionProvider`, because that package’s controller-based query discovery needs MVC services this host does not register. ## Complete example Replace `Program.cs` with this runnable checkpoint. Keep these types in the global namespace below the top-level statements: the command’s conventional route is `/api/greet`. The query uses an explicit `[Path]`. ```csharp using Cratis.Arc; using Cratis.Arc.Authorization; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Queries.ModelBound; var builder = ArcApplication.CreateBuilder(args); builder.AddCratisArc(options => options.Hosting.ApplicationUrl = "http://localhost:5000/"); var app = builder.Build(); app.UseCratisArc(); await app.RunAsync(); [AllowAnonymous] [Command] public record Greet(string Name) { public string Handle() => $"Hello, {Name}!"; } [ReadModel] public record Greeting(string Text) { [AllowAnonymous] [Path("/greeting")] public static Greeting Get(string name) => new($"Hello, {name}!"); } ``` The build reports two `CA1050` warnings because the command and read model sit in the global namespace; that is expected in this single-file checkpoint and is what gives the command its short `/api/greet` route. `AddCratisArc` registers services and binds options. `UseCratisArc` maps Arc endpoints and schedules listener startup; `RunAsync` starts the host and waits for shutdown. Both registration and activation matter. This lesson deliberately permits anonymous access; read [authentication](/arc/backend/csharp/core/authentication/) and [authorization](/arc/backend/csharp/core/authorization/) before exposing private operations. ## Run and observe Start the application: ```bash dotnet run ``` In another terminal, call the command and query: ```bash curl -X POST http://localhost:5000/api/greet \ -H "Content-Type: application/json" \ -d '{"name":"World"}' curl 'http://localhost:5000/greeting?name=World' ``` The command result’s `response` is `"Hello, World!"`; the query result’s `data` contains `{"text":"Hello, World!"}`. Both use Arc result wrappers. Neither operation persists anything. Stop the service with Ctrl+C. Arc builds lowercase, kebab-cased URLs from the configured route prefix, namespace segments, and command/query name. A query’s or command’s `[Path]` overrides that convention (import the shared attribute from `Cratis.Arc.Queries.ModelBound` for both). Use [route configuration](/arc/backend/csharp/asp-net-core/configuration/#route-generation-examples) rather than guessing a URL from the project name. ## Configuration The following **configuration fragment** moves the listen URL into `appsettings.json` under the default `Cratis:Arc` section: ```json { "Cratis": { "Arc": { "Hosting": { "ApplicationUrl": "http://localhost:5000/" } } } } ``` Remove the URL callback to let configuration supply it. Code options override configuration binding. The default listener URL is `http://+:5001/`; use an explicit loopback URL for local lessons. The [complete options reference](/arc/backend/csharp/configuration/) covers environment variables, custom sections, and host-specific APIs. ## Adding services and integrations `ArcApplicationBuilder` exposes standard .NET `Services`, `Configuration`, `Environment`, `Logging`, and `Metrics`. Register application services before `Build()` and inject them into `Handle()` or static query parameters. Background workers use `AddHostedService()` with the normal .NET hosted-service lifecycle. Choose integrations only when you need them: * [MongoDB](/arc/backend/csharp/mongodb/) and [Entity Framework Core](/arc/backend/csharp/entity-framework/) add persistence without requiring event sourcing. * [Chronicle](/arc/backend/csharp/chronicle/) adds optional event sourcing, including its own package, registration, and activation requirements. `UseCratisArc()` alone does not activate the Chronicle client. * [Endpoint mapping](/arc/backend/csharp/core/endpoint-mapping/), [static files](/arc/backend/csharp/core/static-files/), and [lightweight OpenAPI](/arc/backend/csharp/core/openapi/) are already available in Core. ## Troubleshooting If no endpoints respond, verify `AddCratisArc()`, `UseCratisArc()`, and `RunAsync()` are all present. For listener failures, check the port, OS URL-binding permissions, and `Hosting.ApplicationUrl`. For deployed configuration files, ensure they are copied to the output directory. Normal activation also maps [introspection](/arc/backend/csharp/introspection/) and [identity discovery](/arc/backend/csharp/identity/development-and-topologies/) endpoints. They are anonymous only in Development by default and require authentication elsewhere; review the [production access settings](/arc/backend/csharp/introspection/#production-access) before publishing the service. `GET /.cratis/queries` describes discovered performers, not the final route table: this example’s `Get` entry reports `/greeting`, matching its `[Path]`. Introspection does not apply all final mapping/deduplication decisions. ## Next steps * [Commands](/arc/backend/csharp/commands/) — validation, return values, and execution. * [Queries](/arc/backend/csharp/queries/) — data retrieval and observation. * [Authentication](/arc/backend/csharp/core/authentication/) — establish trusted principals. * [Tenancy](/arc/backend/csharp/tenancy/) — select a tenant and enforce membership separately. # Invariant Culture > Make an Arc.Core host use invariant culture with UseInvariantCulture() so parsing and formatting do not depend on machine settings. Arc.Core provides a `.UseInvariantCulture()` extension method on `IHostBuilder` that configures the application to use invariant culture throughout, ensuring consistent and predictable behavior regardless of the host machine’s regional settings. ## Why Invariant Culture? Applications running in distributed environments—different machines, containers, or cloud regions—may be configured with different regional settings. Without explicit culture configuration, operations like number parsing, date formatting, or string comparisons may produce inconsistent results depending on where the code runs. Invariant culture supplies a predictable default for code that uses the current culture; explicit per-call or per-thread culture choices can still override it: * **DateTime formatting and parsing** uses invariant conventions by default; it does not choose a time zone * **Number formatting and parsing** produces consistent results (e.g., decimal separator is always `.`) * **String comparisons and sorting** are culture-independent * **Serialization and deserialization** still follows each serializer’s contract and explicit options ## Configuration For an `ArcApplication`-based host (a worker or console service), set the default thread cultures to invariant before building the application: ```csharp using System.Globalization; using Cratis.Arc; CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; var builder = ArcApplication.CreateBuilder(args); builder.AddCratisArc(); var app = builder.Build(); app.UseCratisArc(); await app.RunAsync(); ``` For a generic host, the `UseInvariantCulture()` extension on `IHostBuilder` does the same thing in one call: ```csharp using Cratis.Arc; using Microsoft.Extensions.Hosting; var host = Host.CreateDefaultBuilder(args) .UseInvariantCulture() .Build(); ``` ## What Gets Configured Both approaches set: * `CultureInfo.DefaultThreadCurrentCulture` to `CultureInfo.InvariantCulture` * `CultureInfo.DefaultThreadCurrentUICulture` to `CultureInfo.InvariantCulture` This ensures that all threads in the application, including background threads and thread pool threads, use invariant culture by default. ## When to Use Invariant culture is recommended for: * **Background services and workers** that process data without a web request context * **Applications deployed in multiple regions** or cloud environments * **Data processing pipelines** where consistency is critical * **Any scenario** where culture-sensitive behavior could lead to bugs or data inconsistencies It is generally safe to apply invariant culture in back-end services, as locale-specific formatting should be handled on the client side rather than in the service itself. ## ASP.NET Core Applications If you are building an ASP.NET Core application, use the `WebApplicationBuilder` extension instead, which additionally configures the ASP.NET Core request localization middleware. See [Invariant Culture for ASP.NET Core](/arc/backend/csharp/asp-net-core/invariant-culture/) for details. # Lightweight OpenAPI specifications > Expose route metadata with Arc.Core's built-in OpenAPI endpoint, without the ASP.NET integration package. When you need a route catalog for a lightweight service, Core can generate a basic route document declaring OpenAPI 3.0, with the compatibility limitations below. This support is already in **`Cratis.Arc.Core`**. The separate `Cratis.Arc.OpenApi` package supplies [ASP.NET Core transformers](/arc/backend/csharp/open-api/); it is not required for `ArcApplication.MapOpenApi()`. ## Getting started This runnable `Program.cs` checkpoint assumes the console project from [getting started](/arc/backend/csharp/core/getting-started/): ```csharp using Cratis.Arc; using Cratis.Arc.Http; using Cratis.Arc.OpenApi; var builder = ArcApplication.CreateBuilder(args); builder.AddCratisArc(options => options.Hosting.ApplicationUrl = "http://localhost:5000/"); var app = builder.Build(); app.UseCratisArc(); app.MapGet("/health", context => context.Write("OK"), new EndpointMetadata( Name: "Health", Summary: "Reports that the service is running", Tags: ["Operations"], AllowAnonymous: true)); app.MapOpenApi(); await app.RunAsync(); ``` `curl http://localhost:5000/openapi.json` returns a document with `openapi: "3.0.0"` and a `/health` path, alongside Arc’s mapped routes. Mapping the document does not register Arc services or start the listener; the bootstrap above does both. ## Configuration options `MapOpenApi` returns the application and accepts these optional arguments: | Argument | Default | | --- | --- | | `pattern` | `/openapi.json` | | `title` | `Arc Application` | | `version` | `1.0.0` | This **mapping fragment** replaces `app.MapOpenApi()` in the checkpoint: ```csharp app.MapOpenApi( pattern: "/api/openapi.json", title: "Customer Management API", version: "1.0.0"); ``` ## Generated document structure The generator reads currently registered routes when the document is requested. Endpoint metadata supplies operation IDs, summaries, and tags. The server URL is `/`. It emits generic 200, 401, and 500 response descriptions for each operation, not a comprehensive status contract. For metadata with `AllowAnonymous = false`, it emits a Bearer/JWT security requirement and scheme. That is a **fixed documentation convention**: it does not inspect or install your actual authentication mechanism. It may not describe an API-key or forwarded-header deployment accurately. ## Limitations and production access * No request-body or response-body schemas are generated. Do not use this document alone to generate a complete typed client. * The document endpoint is explicitly anonymous. Restrict it at trusted ingress if route metadata should not be public. * Documentation does not enforce permissions; see [authentication](/arc/backend/csharp/core/authentication/) and [manual endpoint boundaries](/arc/backend/csharp/core/endpoint-mapping/). * The generator emits eligible registered HTTP methods as lowercase path-item members. OpenAPI 3.0 cannot represent a `query` operation: strict tools may reject the document or ignore that member if you manually expose it. The generated QUERY reader is excluded, as are commands or queries marked `[ExcludeFromDiscovery]`; the ASP.NET mapper also excludes these from its API descriptions. * If you choose to disable the QUERY transport, set `options.GeneratedApis.EnableQueryHttpMethod = false` in the `AddCratisArc` callback. Generated queries then accept GET only. This is an application transport choice, not a fix to the generator or a requirement to run Arc; verify clients do not depend on QUERY. * The public lightweight mapping helpers currently expose GET and POST, not ASP.NET’s full routing API. Validate compatibility before importing the document into Postman or pointing a separately hosted Swagger UI at it. For richer ASP.NET type schemas, use [Cratis.Arc.OpenApi](/arc/backend/csharp/open-api/) or [Cratis.Arc.Swagger](/arc/backend/csharp/asp-net-core/swagger/), and review their documented limitations too. ## See also * [Endpoint mapping](/arc/backend/csharp/core/endpoint-mapping/) — supply route metadata. * [Introspection](/arc/backend/csharp/introspection/) — command/query contract discovery and exposure defaults. * [Getting started](/arc/backend/csharp/core/getting-started/) — a complete standalone command/query checkpoint. # Lightweight Core overview > Choose Arc.Core for standalone pipelines and HttpListener hosting, or Arc for ASP.NET integration. A worker or small HTTP service does not always need MVC and Kestrel. `Cratis.Arc.Core` gives you Arc’s command/query pipelines and a lightweight `HttpListener` host without an ASP.NET Core dependency. `Cratis.Arc` adds the ASP.NET integration when you need that ecosystem. Neither package requires event sourcing. ## What Core provides * Model-bound commands and queries, validation, authorization, and result handling. * Identity details and tenant context; application authorization and tenant membership still need deliberate configuration. * Correlation IDs, dependency injection, and convention-based discovery. * GET/POST manual endpoints, [static files and SPA fallback](/arc/backend/csharp/core/static-files/), and [basic OpenAPI route metadata](/arc/backend/csharp/core/openapi/). ``` Your application Cratis.Arc.Core Command and query pipelines Optional lightweight HTTP host Cratis.Arc ASP.NET integration Optional MongoDB or EF Core integration Optional Chronicle event sourcing ``` Persistence integrations do not require Chronicle. Adding an integration can introduce its own dependencies; the Core-only dependency boundary is not a promise about every combined package graph. ## Comparison with ASP.NET Core | Capability | Arc.Core lightweight host | Arc with ASP.NET Core | | --- | --- | --- | | Package | `Cratis.Arc.Core` | `Cratis.Arc` | | HTTP server | `HttpListener` | ASP.NET hosting, commonly Kestrel | | Request handling | Arc’s simplified route/static/fallback handling | ASP.NET middleware ecosystem | | Manual routing | Literal-path GET/POST helpers | ASP.NET routing APIs | | Static files | Built-in support and SPA fallback | ASP.NET static-file middleware | | MVC/Razor | Not included | Available through ASP.NET | | OpenAPI | Basic route metadata in Core | Optional `Cratis.Arc.OpenApi` or `Cratis.Arc.Swagger` | | Pipelines, validation, identity, tenant context | Available | Available | Core does have request-processing middleware internally; it does not expose ASP.NET’s full middleware pipeline. Native AOT compatibility, startup time, memory usage, deployment size, and throughput depend on discovery, serialization, integrations, and deployment. Validate your actual publish configuration and benchmark your workload rather than treating “lightweight” as an AOT or performance guarantee. ## When to choose each host Choose Core for small standalone HTTP services, workers with management endpoints, or learning the pipelines without a web-framework dependency. Choose ASP.NET Core for controllers, Razor, advanced routing, standard authentication middleware, or other ASP.NET libraries. Arc pipelines can participate in other application architectures, but Core does not itself supply a gRPC server or arbitrary protocol implementation. ## Architecture The builder registers services; activation maps Arc endpoints and schedules listener startup. This **bootstrap fragment** belongs in a console project’s `Program.cs` with `using Cratis.Arc;`: ```csharp var builder = ArcApplication.CreateBuilder(args); builder.AddCratisArc(); var app = builder.Build(); app.UseCratisArc(); await app.RunAsync(); ``` The default listener binds `http://+:5001/`; the [getting-started checkpoint](/arc/backend/csharp/core/getting-started/) uses an explicit loopback URL. Review [discovery access defaults](/arc/backend/csharp/introspection/) before exposing the host. ## Next steps * [Getting started](/arc/backend/csharp/core/getting-started/) — run a complete command/query example. * [Authentication](/arc/backend/csharp/core/authentication/) and [authorization](/arc/backend/csharp/core/authorization/) — establish and enforce trust. * [Configuration](/arc/backend/csharp/configuration/) — all runtime options and host differences. * [Invariant culture](/arc/backend/csharp/core/invariant-culture/) — culture-sensitive behavior. * [Chronicle integration](/arc/backend/csharp/chronicle/) — optional event sourcing, not a prerequisite. # Static Files > Serve static files and a single-page application fallback from the lightweight Arc.Core host. Arc.Core provides built-in support for serving static files, similar to the `UseStaticFiles()` middleware in ASP.NET Core. This is essential for hosting Single Page Applications (SPAs), serving assets like CSS, JavaScript, images, and other static content. Except for the complete SPA example, code blocks below are configuration fragments for the [Core host](/arc/backend/csharp/core/getting-started/), with `using Cratis.Arc;` in scope. Supply the static asset files yourself; Arc does not build a frontend bundle. ## Basic Usage To serve static files from the default `wwwroot` directory: ```csharp var builder = ArcApplication.CreateBuilder(args); builder.AddCratisArc(); var app = builder.Build(); // Enable static file serving from wwwroot app.UseStaticFiles(); app.UseCratisArc(); await app.RunAsync(); ``` > **Important**: Configure static files and fallback before `StartAsync()` or `RunAsync()`. `UseCratisArc()` schedules listener startup; it does not start the listener immediately. Static-file configuration may precede or follow that call. ## Configuration Options You can customize static file serving using `StaticFileOptions`: ```csharp app.UseStaticFiles(options => { // Serve files from a custom directory options.FileSystemPath = "public"; // Add a URL prefix for static files options.RequestPath = "/static"; // Enable/disable serving default files (index.html, etc.) options.ServeDefaultFiles = true; // Customize which files are considered "default" files options.DefaultFileNames = ["index.html", "default.html"]; // Add custom MIME type mappings options.ContentTypeMappings["myext"] = "application/x-custom"; }); ``` ### StaticFileOptions Properties | Property | Type | Default | Description | | --- | --- | --- | --- | | `FileSystemPath` | `string` | `"wwwroot"` | The directory to serve files from | | `RequestPath` | `string` | `""` (root) | URL prefix for static file requests | | `ServeDefaultFiles` | `bool` | `true` | Whether to serve index.html for directory requests | | `DefaultFileNames` | `IList` | `["index.html", "index.htm", "default.html", "default.htm"]` | Files to look for when requesting a directory | | `EnableDirectoryBrowsing` | `bool` | `false` | Whether to allow directory listing (not yet implemented) | | `ContentTypeMappings` | `IDictionary` | Empty | Custom file extension to MIME type mappings | ## File System Path Resolution Arc.Core resolves relative paths in the following order: 1. **Current Working Directory** - First checks if the path exists relative to `Directory.GetCurrentDirectory()`. This is typically your project directory when running with `dotnet run`. 2. **Application Base Directory** - Falls back to `AppContext.BaseDirectory` (the `bin/Debug/net10.0/` output folder) if not found in the current directory. This behavior matches how ASP.NET Core resolves content root paths during development and ensures your `wwwroot` folder is found whether you’re running from the project directory or from the compiled output. ### Example: Custom Static Directory ```csharp // Serve from 'public' folder in your project directory app.UseStaticFiles(options => { options.FileSystemPath = "public"; }); // Serve from an absolute path app.UseStaticFiles(options => { options.FileSystemPath = "/var/www/static"; }); ``` ## SPA Fallback with MapFallbackToFile For Single Page Applications that use client-side routing, you need to serve your `index.html` for any route that doesn’t match a static file or API endpoint. Use `MapFallbackToFile()`: ```csharp var app = builder.Build(); // Serve static files app.UseStaticFiles(); // Fallback to index.html for SPA routing app.MapFallbackToFile(); // Defaults to "index.html" // Or specify a custom file app.MapFallbackToFile("app.html"); app.UseCratisArc(); await app.RunAsync(); ``` The fallback file is served when: 1. No registered API route matches the request 2. No static file matches the request path 3. The request method is GET 4. The fallback file exists in the static files directory ## Complete SPA Example Here’s a complete example for hosting a React, Angular, or Vue SPA: ```csharp using Cratis.Arc; var builder = ArcApplication.CreateBuilder(args); builder.AddCratisArc(); var app = builder.Build(); // Serve static files from wwwroot app.UseStaticFiles(); // Enable SPA fallback routing app.MapFallbackToFile(); // Configure Arc (maps API endpoints) app.UseCratisArc(); await app.RunAsync(); ``` With this configuration: * `/` → Serves `wwwroot/index.html` * `/styles.css` → Serves `wwwroot/styles.css` * `/js/app.js` → Serves `wwwroot/js/app.js` * `/dashboard/users/123` → Serves `wwwroot/index.html` (SPA route) * `/api/users` → Handled by a command or query only if one is mapped to that route; otherwise an unmatched GET also serves `wwwroot/index.html` ## Hosting under a path prefix In the lightweight Arc.Core host, `app.UsePathBase("/workbench")` prefixes endpoints mapped **after** that call, including Arc endpoints when called before `app.UseCratisArc()`. It does not prefix static files or scope `MapFallbackToFile()` to `/workbench`. Set `StaticFileOptions.RequestPath` explicitly for assets you want under that path, and account for the fallback’s behavior on unmatched GET requests separately. In the frontend, set `apiBasePath` for Arc requests and your router’s `basename` for navigation; see [React provider configuration](/arc/frontend/react/arc/#hosting-under-a-path-prefix). ## Multiple Static File Configurations You can call `UseStaticFiles()` multiple times to serve from different directories: ```csharp // Serve from wwwroot at root app.UseStaticFiles(); // Serve uploaded files from a different directory app.UseStaticFiles(options => { options.FileSystemPath = "uploads"; options.RequestPath = "/files"; }); ``` ## Middleware Order Request precedence is **registered routes → static files → SPA fallback**, regardless of the relative order of these configuration calls. This is not ASP.NET’s ordered middleware pipeline: ```csharp var app = builder.Build(); // Configure static files (checked after registered routes) app.UseStaticFiles(); // Configure SPA fallback (checked last) app.MapFallbackToFile(); // Map Arc routes and schedule listener startup app.UseCratisArc(); await app.RunAsync(); ``` ## MIME Types Arc.Core includes built-in MIME type mappings for common file extensions: | Category | Extensions | | --- | --- | | Text | `.txt`, `.html`, `.htm`, `.css`, `.csv`, `.xml` | | JavaScript | `.js`, `.mjs`, `.jsx`, `.ts`, `.tsx` | | JSON | `.json`, `.map` | | Images | `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.ico` | | Fonts | `.woff`, `.woff2`, `.ttf`, `.otf`, `.eot` | | Documents | `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx` | | Audio | `.mp3`, `.wav`, `.ogg`, `.m4a` | | Video | `.mp4`, `.webm`, `.avi`, `.mov` | | Web | `.wasm`, `.webmanifest` | For unknown extensions, `application/octet-stream` is returned. ### Adding Custom MIME Types ```csharp app.UseStaticFiles(options => { options.ContentTypeMappings[".custom"] = "application/x-custom"; options.ContentTypeMappings[".data"] = "application/octet-stream"; }); ``` ## Security Serve only public assets from dedicated directories; do not place secrets or private uploads in a served tree. Static files are not command/query pipeline operations and do not inherit their role checks. The implementation normalizes paths and checks containment, but do not treat this as a security certification for every filesystem or symlink arrangement. Test your deployment and enforce private-file access through an authorized endpoint instead. # Generate a Microsoft identity principal for local development > Simulate trusted-ingress principal headers on a loopback-only development host, without confusing Base64 assertions with validated bearer tokens. Need to exercise different users and roles before wiring production login? The [Microsoft identity header adapter](/arc/backend/csharp/asp-net-core/microsoft-identity/) can construct a principal from development HTTP headers. This is **identity simulation**, not token validation. ## Understand the trust boundary first `x-ms-client-principal` is Base64-encoded JSON — an **unsigned assertion**, not a signed bearer token. Arc’s header handlers decode it and construct claims; they do not validate a signature, issuer, or audience. Header presence and valid JSON do not prove who sent it. That is why Arc ignores these headers until the host opts in with `Cratis:Arc:TrustForwardedIdentityHeaders`; your loopback development host has to opt in too, as the [authorization tutorial](/arc/tutorial/authorization/) shows. Use manually supplied headers only on an isolated, loopback-bound development host. In production, either configure a real token/cookie authentication scheme or accept these assertions only from a trusted authenticated ingress. That ingress must **strip and replace all incoming identity headers**, and the backend must not be reachable through a bypass route. An arbitrary browser or proxy sending these headers is not trustworthy. The [authorization tutorial](/arc/tutorial/authorization/) shows the ASP.NET registration and middleware placement for a Development-only fixture. The [lightweight host authentication](/arc/backend/csharp/core/authentication/) is a different setup; do not mix their APIs. ## Construct the assertion The adapter expects all three headers: | Header | Content | | --- | --- | | `x-ms-client-principal` | Base64 JSON following the [client principal shape](https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=csharp#client-principal-data) | | `x-ms-client-principal-id` | The selected user’s stable identifier | | `x-ms-client-principal-name` | The selected user’s display name or email | Save this synthetic fixture as `principal.json` in your local application workspace: ```json { "identityProvider": "aad", "userId": "e7f664ca-4ecc-45be-84cf-74b6240d049a", "userDetails": "jane.doe@contoso.com", "userRoles": ["anonymous", "authenticated", "Librarian"], "claims": [ { "typ": "preferred_username", "val": "jane.doe@contoso.com" }, { "typ": "name", "val": "Jane Doe" }, { "typ": "given_name", "val": "Jane" }, { "typ": "family_name", "val": "Doe" }, { "typ": "oid", "val": "e7f664ca-4ecc-45be-84cf-74b6240d049a" } ] } ``` `userRoles` is mapped to role claims by the header adapter. Include `Librarian` for the successful tutorial case; remove it for an authenticated-but-not-authorized case. A role field returned by `IProvideIdentityDetails` would not have the same effect. Encode locally, keeping the result on one line: ```bash python3 -c 'import base64,pathlib; print(base64.b64encode(pathlib.Path("principal.json").read_bytes()).decode())' ``` A local editor’s Base64 action is another option. Do not submit real identity payloads to an online encoder. Base64 provides neither confidentiality nor authenticity. ## Configure ModHeader narrowly In [ModHeader](https://modheader.com), create a development-only profile and **add an include URL filter before enabling it**. For the tutorial Vite server, use `^http://localhost:5173/.*$`. Add a separate `^http://localhost:5000/.*$` only when testing that backend directly. If you use a different local port, update the filter; never select all requests. Set: * `x-ms-client-principal-id`: `e7f664ca-4ecc-45be-84cf-74b6240d049a` * `x-ms-client-principal-name`: `jane.doe@contoso.com` * `x-ms-client-principal`: the one-line Base64 output above Caution The older screenshot below illustrates the header fields only. Its **“All requests”** setting is unsafe and must not be copied. Apply the localhost include filter above, and disable the profile when finished. ![Older ModHeader header-field example; replace its All requests scope with a localhost include filter](/_astro/configure-mod-header.gqMtjH-v_1WS9Wy.webp) Use separate filtered profiles for different synthetic users. Confirm in browser developer tools that headers are sent only to your intended localhost application. ## Verify the distinct behaviors * No headers: protected operations reject an unauthenticated caller. * Valid fixture without `Librarian`: authentication succeeds but role-protected operations reject access. * Fixture with `Librarian`: role-protected operations can execute, subject to their other checks. * Identity-details provider returning `IsUserAuthorized: false`: `/.cratis/me` rejects that identity result. This does **not** replace operation authorization. These checks test the adapter and your authorization configuration, not production token verification. For production, independently test ingress header replacement, blocked direct backend access, and the real authentication scheme. See [identity and access](/arc/understanding-identity-and-access/) for how claims, details, operation permissions, and tenant membership differ. # Browse your embedded event model > Generate Screenplay documents during compilation and expose a read-only event-model explorer in an ASP.NET Core application. To inspect the model behind a running application without keeping a separate diagram up to date, embed its generated Screenplay documents during the build. `Cratis.Arc.Screenplay.Embedded` adds a read-only explorer at `/.cratis/event-model` with project, module, and feature navigation. ## Quickstart 1. Add the package to your ASP.NET Core application (skip this if you already reference the `Cratis` metapackage): ```bash dotnet add package Cratis.Arc.Screenplay.Embedded ``` 2. Map the viewer after `builder.Build()` and before `app.Run()`: ```csharp if (app.Environment.IsDevelopment()) { app.MapCratisEventModel(); } ``` With the `Cratis` metapackage, your existing `AddCratis` / `UseCratis` setup does this automatically for a Debug build running in Development; no explicit mapping is needed. 3. Run in Development and open `http://localhost:/.cratis/event-model/`, using the port printed by your application: ```bash dotnet run --configuration Debug -- --environment Development ``` See [Security](#security) before exposing the viewer beyond your local development machine. This is source-derived documentation, not a view of stored events or live application data. Use it to review a feature’s command/event/read-model flow, orient yourself in an unfamiliar codebase, or compare a scoped feature with the whole application without maintaining a second diagram. It uses the same analysis as [Screenplay generation](/arc/backend/csharp/generating-a-screenplay/); its limitations and diagnostics still apply. Arc does not require event sourcing. An ordinary command that returns a response has no event card. Commands that produce recognized Chronicle events show those events, including events declared in another slice; state-view slices show the events they consume. ## Open the explorer in a Cratis application The `Cratis` metapackage includes the embedded-generation package. In an existing ASP.NET Core host configured with `AddCratis` and activated with `UseCratis`, build and run in Debug with the hosting environment set to Development, then open `/.cratis/event-model/`. The build creates and embeds the documents automatically; `UseCratis` maps the viewer automatically only when both conditions hold. The build check uses the application’s `DebuggableAttribute` and whether optimizations are disabled. Production, Staging, and other environments do not expose the viewer automatically, even for a Debug build. Release-built applications do not expose it automatically, even in Development. Through the metapackage, Release builds also disable generation by default. To opt out, configure services before building the host: ```csharp builder.Services.AddCratisEventModelViewer(options => options.Enabled = false); ``` For Arc-only hosts, or when you want to choose the exposed assemblies and policy yourself, use the explicit setup below. ## Map the explorer explicitly In an ASP.NET Core application that already declares Arc commands or queries, add the package: ```bash dotnet add package Cratis.Arc.Screenplay.Embedded ``` Map the explorer in your existing `Program.cs` after building the application. This is a complete minimal host; retain your application’s existing Arc setup when adding the mapping: ```csharp var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.MapCratisEventModel(); } app.Run(); ``` Build and start your application, then open `/.cratis/event-model/`. Select a document in the hierarchy to view its event-model board or its generated `.play` source. The viewer is bundled in the package; your application’s users do not need to install Node.js. Outside automatic metapackage hosting, nothing is mapped until you call the extension. By default it reads the entry assembly. For an application whose model lives in other assemblies, select them explicitly: ```csharp app.MapCratisEventModel(typeof(MyApplicationMarker).Assembly); ``` `MyApplicationMarker` is a type in the assembly containing your application artifacts. Each selected assembly must have been built with the embedded-generation package enabled. Documents remain assembly-scoped; this does not merge multiple projects into one model. ## Keep it out of release builds Endpoint mapping and resource generation are separate switches. An unmapped explorer cannot be requested, but generated resources still reside in the assembly unless you disable generation. To generate documents only in Debug, add this to your application project: ```xml false ``` A direct reference to `Cratis.Arc.Screenplay.Embedded` enables generation by default; the `Cratis` metapackage disables it in Release unless you explicitly set `CratisEmbeddedScreenplayEnabled` to `true`. Generation does not run during design-time builds. A real build regenerates the resources from the current source. When none of the sources, references, analyzers or settings they are generated from changed since the last build, the previous output is reused and the application is not analyzed again; a build that reported a warning always runs again so the warning is shown again. Disabling generation prevents previously generated files from being included. ## Cap validated generation for an older consumer The embedded generator uses the same validated-generation admission as standalone Screenplay generation. String-backed strategies with fully recoverable concept rules need ESM v20. To keep documents below that boundary for an older consumer, set: ```xml 19.0 ``` This preserves the validator but withholds validated generation and dependent mappings, responses and pins. Diagnostics name the required version and the configured cap. Use `20.0`, or leave the property unset, to admit supported V20 contracts. Authoring-only output respects the same boundary; it does not make opaque or refuted validation executable. A custom host sets `EmbeddedDocumentOptions.MaximumExecutableModelVersion` to `SemanticVersion.V19` or `SemanticVersion.V20` for the corresponding behavior. See [Validated generation](/arc/backend/csharp/generating-a-screenplay/#validated-generation-and-esm-v20) for proof diagnostics, UUID validation limits and fixture handling. ## Compiler features and generated source Embedded generation forwards the build’s `Features`, `InterceptorsNamespaces`, and legacy `InterceptorsPreviewNamespaces` properties to its C# compilation and source-generator driver. This includes namespaces enabled by SDK features such as OpenAPI interceptors; you do not need to disable embedded generation or remove source generators. `Features` uses Csc’s semicolon-, comma-, or space-separated feature syntax. The two interceptor namespace properties are combined as a semicolon-separated list. An explicit `InterceptorsNamespaces` entry in `Features` takes precedence over that combined list, matching Csc. Keep these settings on your application project; there is no separate embedded-generation override. Custom build hosts can supply the same values through the `Features`, `InterceptorsNamespaces`, and `InterceptorsPreviewNamespaces` task parameters, or through the corresponding parameters of the `SourceCompilation.Create` overload. The existing overload remains available for callers without compiler features. ## Understand the hierarchy Namespace placement is relative to your project’s `RootNamespace`. Set it explicitly when your assembly name and application namespace differ: ```xml Company.Library ``` For example: | Namespace | Placement | | --- | --- | | `Company.Library.Catalog.Authors.Registration` | Module `Catalog`, feature `Authors`, slice `Registration` | | `Company.Library.Catalog.Books.Listing` | Module `Catalog`, feature `Books`, slice `Listing` | | `Company.Library.Accounts.SignIn` | Rooted feature `Accounts`, slice `SignIn` | The first segment after the root is a module when it contains feature namespaces beneath it. If its children are only namespaces identified as slices, it is a rooted feature rather than a module. Nested features remain nested. The assembly document includes everything; scoped documents describe the selected module or feature and its descendants. Screenplay’s current grammar requires a module container around features. Rooted features use the application’s root container in the `.play` text; they remain direct children of the assembly in the explorer rather than being promoted to modules. Slice identification comes from recognized application artifacts, not a fixed list of folder names. Shared supporting types do not turn an arbitrary namespace into a slice. Cross-scope event references remain explicit dependencies in scoped documents. ## Security The viewer is development-only by default: the metapackage’s automatic hosting requires both a non-optimized (Debug) build and `IHostEnvironment.IsDevelopment()` at runtime. A Debug build deployed to Production or Staging no longer exposes the viewer automatically. The event model reveals application structure The viewer is read-only, but its documents describe commands, events, read models, and feature boundaries. The default does not require authentication in Development. Do not expose it to untrusted callers, including on a shared development host. Explicit `app.MapCratisEventModel()` calls work in every environment and bypass the automatic exposure options. To opt in deliberately outside Development, apply your application’s authorization policy to the returned route group: ```csharp app.MapCratisEventModel().RequireAuthorization("Developers"); ``` This assumes your host has already registered authentication and the `Developers` policy. The group protects the viewer, static assets, and every document query together. Explicit and automatic mapping reuse the same group, so adding authorization does not create a second unprotected copy. To opt into automatic hosting outside Development, explicitly enable it and configure the same policy during service registration: ```csharp builder.Services.AddCratisEventModelViewer(options => { options.Enabled = true; options.RequireAuthorization = true; options.AuthorizationPolicy = "Developers"; }); ``` `Enabled = true` explicitly opts into automatic hosting for any build and environment; it does not add authorization or generate documents. Set `CratisEmbeddedScreenplayEnabled` to `true` as well if you want resources in Release. `Assemblies` lets you name additional application assemblies; automatic selection otherwise uses the entry assembly and its already-loaded, directly referenced application assemblies, not framework assemblies or arbitrary loaded libraries. ## Explore the model The solid sidebar lists each assembly and its module/feature documents. Selecting a document opens a read-only board to the right; its initial camera centers and fits the model. The lower-right rounded toolbar controls zoom and the minimap. Pan and zoom freely after opening a document: the initial fit does not keep overriding your camera. ![Read-only event-model board with command and event cards, the solid project sidebar, and lower-right zoom controls](/_astro/embedded-event-model-board.SxQvWS0L_Z1TnIaw.webp) Open **View** in the upper right to choose: * **Full** or **Overview** detail level. * **Properties** to show or hide card properties. * **Arrows** or **Lines** visualization. These choices are stored in your browser’s local storage and restored on the next visit. If the browser blocks persistence, the viewer applies the choice but displays a warning that it could not remember it. Each slice shows the specifications the document declares for it, in declaration order. A specification is drawn as cards: the events given before it, what it does, the events it expects and the errors it expects (`then denied` appears as an error named `denied`). What it does is the slice’s own command when it runs it, with the values it sets; any other action is named for its kind, such as `append AuthorRegistered` or `clock 2026-10-05T08:00:00Z`, with its values. An event stated `for` a source carries that source in its card’s name. What a specification states that has no card travels in its header, after its name: the caller, the clock, generated values, read models given or expected, read models expected to be absent, the response `then returns` expects, query results, operations, and stream routes. Nothing a specification states is dropped, so the board and the generated source always say the same thing. The board does not run specifications; it has no run outcome to show. ![Upper-right View menu showing detail level, property visibility, and visualization choices](/_astro/embedded-event-model-view-options.CZ93-LNj_Z16g4wY.webp) Choose **Source** to inspect the generated Screenplay for the selected document, highlighted the way the Screenplay editor shows it. The source is read-only because it is generated from your code. This is useful when a conversion warning says a declaration or mapping cannot be represented on the board. ![Source tab showing the generated Screenplay with syntax highlighting](/_astro/embedded-event-model-source.Bbc3q59E_pz2y2.webp) ## Query the documents All paths below are relative to `/.cratis/event-model/`: | GET path | Response | | --- | --- | | `hierarchy` | Selected projects and their document hierarchy | | `documents/{projectId}/{documentId}/source` | Generated `.play` text | | `documents/{projectId}/{documentId}/model` | Read-only canvas model with conversion warnings | Use the IDs returned by `hierarchy` and URL-encode each path segment. Unknown projects, documents, and assets return HTTP 404. These queries read embedded resources only; they do not accept filesystem paths or fetch remote documents. The board is a visualization, not another inference engine. Where its card model cannot show everything a Screenplay document expresses, conversion warnings make that limitation visible. Use the source view for the full generated document. Generation errors fail the build instead of embedding a document that the Screenplay compiler rejects. ## Serve the explorer from another host Everything the explorer answers comes from types you can use without mapping its routes. A tool that views an application from the outside — the Cratis CLI’s `cratis view` does exactly this — serves the same viewer over documents it reads from a built assembly or generates in memory: | Type | Package | Role | | --- | --- | --- | | `EventModelCatalog` | `Cratis.Arc.Screenplay.Embedded` | Reads catalogs and document sources from assemblies or from any `IEventModelResources` | | `AssemblyEventModelResources` | `Cratis.Arc.Screenplay.Embedded` | The manifest resources of an assembly built with this package | | `InMemoryEventModelResources` | `Cratis.Arc.Screenplay.Embedded` | The same resources, held in memory under the names an assembly embeds them as | | `EventModelExplorer` | `Cratis.Arc.Screenplay.Embedded` | Answers the hierarchy, a document’s source, and the board model it compiles to | | `EmbeddedViewerAssets.Viewer` | `Cratis.Arc.Screenplay.Embedded` | The viewer’s own page and assets | | `EmbeddedDocumentGenerator` | `Cratis.Arc.Screenplay.Embedded.Generation` | Generates the documents the build would embed, from one or more Roslyn compilations | The following example generates the documents from compilations you have already loaded, without writing any files, and builds an explorer over them: ```csharp using Cratis.Arc.Screenplay.Embedded.Generation; using Cratis.Arc.Screenplay.Embedded.Hosting; using Cratis.Arc.Screenplay.Embedded.Hosting.Catalog; using Microsoft.CodeAnalysis; public static class InMemoryExplorer { public static EventModelExplorer For(IReadOnlyList compilations, string assemblyName, string rootNamespace) { var generation = new EmbeddedDocumentGenerator().Generate(compilations, new EmbeddedDocumentOptions(assemblyName, rootNamespace)); var catalog = EventModelCatalog.For([generation.ToResources(assemblyName)]); return new EventModelExplorer(catalog); } } ``` For documents an assembly already embeds, use `EventModelCatalog.For([assembly])` instead of generating them. To serve the viewer, answer `GET` requests relative to the page the viewer is served from: the page itself from `EmbeddedViewerAssets.Viewer.TryReadIndex`, `assets/{path}` from `TryRead`, and the three paths in [Query the documents](#query-the-documents) from `EventModelExplorer`. Serialize models with `EventModelJson.SerializerOptions` so the board can read them. # Entity Framework Core > Optional relational persistence for standalone Arc applications. Add relational persistence when your Arc application needs it. The optional `Cratis.Arc.EntityFrameworkCore` package provides context registration, model conversion, and observation without requiring Chronicle or event sourcing. Begin with [getting started](/arc/backend/csharp/entity-framework/getting-started/); registration selects a provider but does not create your schema. ## Topics | Topic | Description | | --- | --- | | [Getting Started](/arc/backend/csharp/entity-framework/getting-started/) | How to configure Entity Framework Core with Arc, including auto-discovery and observation support. | | [Base DbContext](/arc/backend/csharp/entity-framework/base-db-context/) | How to use the base DbContext class provided by the Arc. | | [Entity Mapping](/arc/backend/csharp/entity-framework/entity-mapping/) | How to configure entities using IEntityTypeConfiguration\ for clean, organized entity configuration. | | [Read Only DbContexts](/arc/backend/csharp/entity-framework/read-only/) | How to implement read-only database contexts for query scenarios. | | [Automatic Database hookup](/arc/backend/csharp/entity-framework/automatic-database-hookup/) | Provider detection, pooled context registration, and connection-string limits. | | [Upserts](/arc/backend/csharp/entity-framework/upserts/) | Tracked persistence and atomic native upserts for SQL Server, PostgreSQL, and SQLite. | | [Observing DbSet](/arc/backend/csharp/entity-framework/observing/) | How to monitor entity changes in real-time using reactive extensions. | | [Common Column Types](/arc/backend/csharp/entity-framework/common-column-types/) | Common column type configurations and conventions. | | [Property Extensions](/arc/backend/csharp/entity-framework/property-extensions/) | Property configuration extensions for cross-database compatibility. | | [Json](/arc/backend/csharp/entity-framework/json/) | Working with JSON columns and serialization in Entity Framework Core. | | [Geometry storage paths](/arc/backend/csharp/entity-framework/point-conversion/) | Explicit string conversion versus JSON properties and spatial migration declarations. | | [Guid conversion](/arc/backend/csharp/entity-framework/guid-conversion/) | SQLite string conversion and migration type differences. | | [Concept conversion](/arc/backend/csharp/entity-framework/concept-as-conversion/) | Store strongly typed values. | ## Overview The Entity Framework Core integration in the Arc streamlines database operations by providing sensible defaults, automatic configuration, and patterns that work well with CQRS architecture. Whether you’re working with read-write or read-only contexts, the framework handles the complexity of setup and configuration while giving you the flexibility to customize when needed. # Automatic database hookup > Register pooled EF contexts and select a supported provider from a connection string. Out of the box we support the following databases: * Sqlite * PostgreSQL * Microsoft SQL Server The examples below are registration or service fragments for an existing host; supply the named contexts and import `Cratis.Arc.EntityFrameworkCore`, `Microsoft.EntityFrameworkCore`, and `Microsoft.Extensions.DependencyInjection`. Contexts must forward their options to their base constructor. Start with [getting started](/arc/backend/csharp/entity-framework/getting-started/) for the optional package and Arc setup. Database hookup selects a provider; it does not apply migrations. SQLite strings use `Data Source=store.db`; PostgreSQL detection requires `Host=` and `Database=`; SQL Server detection looks for server/data-source and SQL Server-specific keywords. Unrecognized strings throw `UnsupportedDatabaseType`. You can use the standard EF Core method with the Arc database detection extension: ```csharp services.AddDbContext(opt => opt.UseDatabaseFromConnectionString("Data Source=store.db")); ``` > Note: From the connection string it will do the correct `.UseSqlite()`, `.UseNpgsql()` or `.UseSqlServer()` call on the builder. However, **it is recommended** to use the Arc registration methods which use the pooled factory pattern for better performance and to support multiple database providers: ```csharp services.AddDbContextWithConnectionString("Data Source=store.db", (serviceProvider, opt) => opt.EnableDetailedErrors()); ``` This method automatically: * Uses `AddPooledDbContextFactory` for improved performance * Applies all `BaseDbContext` configurations (interceptors, service replacements) * Registers both the factory and a scoped DbContext instance * Supports multiple database providers in the same application ## Multiple Database Providers The Arc Entity Framework integration uses the **pooled factory pattern** (`IDbContextFactory`) internally. Different context types can use different database providers. A single context’s options must select only one provider; this is not a prohibition on multiple providers anywhere in the application’s DI container. The pooled factory approach provides: * **Multiple database provider support** - Different contexts can use different databases (SQLite, SQL Server, PostgreSQL) * **Improved performance** - DbContext instances are pooled and reused * **Reduced memory overhead** - Internal service providers are shared across pooled instances All registration methods automatically register both: * `IDbContextFactory` - For creating DbContext instances * `TDbContext` - Scoped instance created from the factory ### Using DbContext in Your Code You can inject DbContexts directly as you normally would: ```csharp public class MyService { private readonly MyDbContext _context; public MyService(MyDbContext context) { _context = context; } } ``` Or you can use `IDbContextFactory` when you need more control over the DbContext lifetime: ```csharp public class MyService { private readonly IDbContextFactory _contextFactory; public MyService(IDbContextFactory contextFactory) { _contextFactory = contextFactory; } public async Task ProcessAsync() { await using var context = await _contextFactory.CreateDbContextAsync(); // Use context } } ``` > **Important**: Pooling reuses context instances and their options. These helpers capture the supplied connection string; they do not implement tenant-aware database selection. Do not capture request-scoped tenant state in pooled configuration. Test any application-supplied isolation strategy across pooled reuse. ## Read Only DbContexts For any **read-only** `DbContext` there is also an extension method: ```csharp services.AddReadOnlyDbContextWithConnectionString("Data Source=store.db", (serviceProvider, opt) => opt.EnableDetailedErrors()); ``` ## Automatic Registration from Assemblies The framework provides methods to automatically discover and register all `ReadOnlyDbContext` types from specified assemblies: ```csharp // Register all ReadOnlyDbContext types from assemblies with a common options action services.AddReadModelDbContextsFromAssemblies((serviceProvider, opt) => opt.UseDatabaseFromConnectionString(connectionString), assembly1, assembly2); // Register all ReadOnlyDbContext types from assemblies with a connection string services.AddReadModelDbContextsWithConnectionStringFromAssemblies(connectionString, (serviceProvider, opt) => { /* additional options */ }, assembly1, assembly2); ``` ### Registration Filtering Rules When using automatic registration, the framework applies the following filtering rules: 1. **Public Classes Only**: Only `public` DbContext classes will be automatically registered. Internal, private, or protected classes are ignored. 2. **Assembly Membership**: Only DbContext classes that belong to the specified assemblies will be considered for registration. 3. **Attribute-Based Exclusion**: Classes marked with the `IgnoreAutoRegistrationAttribute` will be excluded from automatic registration. ### Excluding DbContexts from Automatic Registration If you have a DbContext that should not be automatically registered (for example, if it requires special configuration or should be registered manually), you can exclude it using the `IgnoreAutoRegistrationAttribute`: ```csharp using Cratis.Arc; using Cratis.Arc.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; [IgnoreAutoRegistration] public class SpecialDbContext(DbContextOptions options) : ReadOnlyDbContext(options) { } ``` This is useful for scenarios where: * The DbContext requires special configuration * You want to register it with different lifetime scopes * It’s used only in specific conditions * You want to register it manually with custom options # Base DbContext > Understand which model conventions BaseDbContext applies and what registration adds. `BaseDbContext` removes repeated model conversion setup. Use it with Arc’s context registration helpers so entity-map discovery and application services are also available. ## Define and register a context Model declarations (supply your application’s `Customer` entity): ```csharp using Cratis.Arc.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; public class StoreDbContext(DbContextOptions options) : BaseDbContext(options) { public DbSet Customers => Set(); } ``` Registration fragment in an existing host: ```csharp services.AddDbContextWithConnectionString( "Data Source=store.db", (serviceProvider, options) => options.EnableDetailedErrors()); ``` Import `Microsoft.Extensions.DependencyInjection` for service registration. The callback is `Action`. ## Converters and entity maps During `OnModelCreating`, the base context applies: * [JSON conversion](/arc/backend/csharp/entity-framework/json/) to properties marked `[Json]`. * [Concept conversion](/arc/backend/csharp/entity-framework/concept-as-conversion/) to `ConceptAs` properties. * [Guid conversion](/arc/backend/csharp/entity-framework/guid-conversion/), which adds string conversion for SQLite. Relevant types include owned entities, types directly exposed through `DbSet`, and entity types referenced directly or through generic properties on those set types. Types stored exclusively inside JSON are excluded from relational concept/Guid mapping. **Geometry conversion is not automatic.** Configure `AsPoint()`, `AsLineString()`, or `AsPolygon()` explicitly, or choose the separate `[Json]` path. See [point conversion](/arc/backend/csharp/entity-framework/point-conversion/) for the storage distinctions. [Entity maps](/arc/backend/csharp/entity-framework/entity-mapping/) are applied only when an `IEntityTypeRegistrar` is available through the options’ application service provider. Arc registration wires this up. Directly constructing a context, or ordinary EF registration without the corresponding services/options, is not equivalent. ## Pooling and customization Arc’s helpers register a pooled `IDbContextFactory` and a scoped context. Configure options at registration time; do not change them in `OnConfiguring` on a pooled context. Contexts created directly through a factory must be disposed by their caller. If you override `OnModelCreating`, call `base.OnModelCreating(modelBuilder)` to retain the conventions. Configuration ordering matters: configure explicit overrides after the base call where appropriate. Pooling does not provide tenant isolation. The helpers capture a connection string rather than resolving one per request. See [registration and provider limits](/arc/backend/csharp/entity-framework/getting-started/#scope-and-provider-limits). # Common column types > Reference for Arc CreateTable column helpers and their SQL type mappings. Arc’s migration helpers select SQL type names for PostgreSQL, SQL Server, and SQLite. They do not configure model value converters or guarantee that an entire migration is portable. ## Available column extension methods These extend EF’s `ColumnsBuilder` and require the current `MigrationBuilder` as their first argument. Import `Cratis.Arc.EntityFrameworkCore`; JSON helpers additionally require `Cratis.Arc.EntityFrameworkCore.Json`. | Method | Additional arguments | Nullability | | --- | --- | --- | | `StringColumn(mb)` | `maxLength: int?`, `nullable: bool`, `defaultValue: string?` | Nullable by default | | `NumberColumn(mb)` | `nullable: bool`, `defaultValue: object?`; `T : INumber` | Nullable by default | | `BoolColumn(mb)` | `nullable: bool`, `defaultValue: bool = false` | Nullable by default | | `AutoIncrementColumn(mb)` | None | Non-nullable | | `GuidColumn(mb)` | `nullable: bool` | Nullable by default | | `DateTimeOffsetColumn(mb)` | `nullable: bool` | Nullable by default | | `JsonColumn(mb)` | None | Non-nullable | There are no `CoordinateColumn`, `PointColumn`, `LineStringColumn`, or `PolygonColumn` create-table helpers. Geometry `Add*Column` methods are a separate API; see [adding columns](/arc/backend/csharp/entity-framework/migrations-add-columns/). ## Database-specific type mappings | Value | PostgreSQL | SQL Server | SQLite | | --- | --- | --- | --- | | String with length | `VARCHAR(n)` | `NVARCHAR(n)` | `TEXT` | | Unlimited string | `TEXT` | `NVARCHAR(MAX)` | `TEXT` | | Boolean | `BOOLEAN` | `BIT` | `INTEGER` | | Guid | `UUID` | `UNIQUEIDENTIFIER` | `BLOB` | | DateTimeOffset | `TIMESTAMPTZ` | `DATETIMEOFFSET` | `TEXT` | | JSON | `jsonb` | `nvarchar(max)` | `text` | | Auto-increment | `INTEGER` + identity-by-default annotation | `BIGINT` + identity annotation | `INTEGER` + autoincrement annotation | `NumberColumn` selects integer/floating/decimal types by `T`. Notably, decimal maps to PostgreSQL `DECIMAL`, SQL Server `DECIMAL(18,2)`, and SQLite `REAL`; unsigned 64-bit values map to `NUMERIC(20,0)` / `DECIMAL(20,0)` / `INTEGER`. Review precision and range requirements instead of assuming lossless interchange across providers. SQLite `GuidColumn` declares `BLOB`, while [AsGuid conversion](/arc/backend/csharp/entity-framework/guid-conversion/) sends strings. These are separate contracts; inspect migrations and test existing-data compatibility. ## Usage examples A complete migration class (associate it with your application’s context in the usual EF migration setup): ```csharp using Cratis.Arc.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations; public class CreateStores : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "Stores", columns: table => new { Id = table.AutoIncrementColumn(migrationBuilder), Name = table.StringColumn(migrationBuilder, maxLength: 100, nullable: false), IsOpen = table.BoolColumn(migrationBuilder, nullable: false) }, constraints: table => table.PrimaryKey("PK_Stores", store => store.Id)); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable("Stores"); } } ``` The helper selects types using `migrationBuilder.ActiveProvider`. Test the generated SQL and model alignment for every provider you deploy. For geometry, choose a [storage path](/arc/backend/csharp/entity-framework/point-conversion/#choose-one-storage-path) before writing a migration. # ConceptAs conversion > Map strongly typed value records to EF provider values. The ConceptAs conversion feature provides automatic type conversion support for Cratis [Concepts](/arc/glossary/) in Entity Framework Core. This feature ensures that domain concepts are properly stored and retrieved from the database while maintaining type safety and domain integrity. ## What it does The ConceptAs conversion automatically configures Entity Framework Core to handle properties that are of `ConceptAs` type. When a concept property is encountered: 1. **Storage**: The underlying primitive value of the concept is stored in the database 2. **Retrieval**: The primitive value is automatically converted back to the concept instance when loaded from the database 3. **Comparison**: Proper value comparison is configured for change tracking and querying With the conversion configured, entities keep their domain types while EF stores the underlying values. You do not need a separate converter for every concept. ## Why it’s important Using ConceptAs conversion provides several key benefits: * **Domain Integrity**: Maintains strong typing and domain semantics throughout your application stack * **Automatic Configuration**: No need for manual value converter setup for each concept property * **Database Efficiency**: Stores only the primitive value, keeping database schema clean and performant * **Type Safety**: Prevents mixing of different concept types that share the same underlying primitive type * **Consistency**: Ensures all concept types are handled uniformly across your application ## Model Usage Your entity models can use concepts directly without any special configuration: ```csharp using Cratis.Concepts; public class Customer { public required CustomerId Id { get; set; } public required CustomerName Name { get; set; } public EmailAddress? Email { get; set; } } public record CustomerId(Guid Value) : ConceptAs(Value); public record CustomerName(string Value) : ConceptAs(Value); public record EmailAddress(string Value) : ConceptAs(Value); ``` Use records because `ConceptAs` is a record. Represent optionality with a nullable concept reference (`EmailAddress?`), not a nullable primitive argument such as `ConceptAs`, which violates the generic constraint. These are standalone value types; no Chronicle identity type is required. With `BaseDbContext`, the conversion will automatically: * Store `CustomerId` as a Guid value (as a string through Arc’s SQLite converter) * Store `CustomerName` and `EmailAddress` as `string` values in the database * Convert back to the appropriate concept types when loading entities ## Manual Configuration If you’re not using the [`BaseDbContext`](/arc/backend/csharp/entity-framework/base-db-context/), you can manually apply ConceptAs conversion in your `DbContext`: ```csharp using Cratis.Arc.EntityFrameworkCore; using Cratis.Arc.EntityFrameworkCore.Concepts; using Microsoft.EntityFrameworkCore; public class StoreDbContext(DbContextOptions options) : DbContext(options) { public DbSet Customers => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { var entityTypes = modelBuilder.Model.GetEntityTypes(); modelBuilder.ApplyConceptAsConversion(entityTypes, Database.GetDatabaseType()); base.OnModelCreating(modelBuilder); } } ``` > Note: This is automatically configured for you when using the [`BaseDbContext`](/arc/backend/csharp/entity-framework/base-db-context/). ## How it works The conversion system uses reflection to: 1. Identify all properties in your entities that implement `ConceptAs` 2. Create appropriate `ValueConverter` instances that convert between the concept and its underlying primitive type 3. Configure `ValueComparer` instances for proper change tracking and equality comparisons 4. Apply these converters to the Entity Framework model builder The conversion is handled by the `ConceptAsConversion.ApplyConceptAsConversion()` extension method, which automatically discovers and configures all concept properties in your model. # Connection resolution > Resolve the Entity Framework Core connection string, database and identity per tenant, including access tokens and separate identities for migrations. By default, every Arc-registered `DbContext` connects with the one configured connection string. Use connection resolution when each tenant’s database sits on its own server or database, or connects with its own identity. For the concepts and the resolvers, read [connection resolution](/arc/backend/csharp/tenancy/connection-resolution/) first; for the endpoint an `Http` resolver calls, see the [connection resolution contract](/arc/connection-resolution-contract/). This page configures resolution for Entity Framework Core only. To configure it once for every storage, set it on Arc instead, as described in [configure it once for the whole application](/arc/backend/csharp/tenancy/connection-resolution/#configure-it-once-for-the-whole-application). What you configure here replaces the application-wide configuration for Entity Framework Core. ## Configure it `EntityFrameworkCoreOptions` bind from the `Cratis:EntityFrameworkCore` section. The `configureOptions` callback runs afterwards and wins. ```json { "Cratis": { "EntityFrameworkCore": { "ConnectionString": "Server=sql.example;Database=app;Encrypt=true", "ConnectionResolution": { "Connection": { "Resolver": "Template", "Template": { "Database": "tenant-{tenant}" } }, "Identity": { "Resolver": "Http", "Http": { "Endpoint": "https://platform.example/connections" } } } } } } ``` ```csharp builder.AddCratisArc(configureBuilder: arc => arc.WithEntityFrameworkCore()); ``` The same configuration in code: ```csharp builder.AddCratisArc(configureBuilder: arc => arc.WithEntityFrameworkCore(configureOptions: options => { options.ConnectionString = "Server=sql.example;Database=app;Encrypt=true"; options.ConnectionResolution.Connection.UseTemplate(database: "tenant-{tenant}"); options.ConnectionResolution.Identity.UseHttp(new Uri("https://platform.example/connections")); })); ``` ## How it works with pooled contexts Arc resolves when a connection **opens**, not when a context is created. A pooled context keeps its connection object between leases, but every lease opens it again for whoever holds the lease. Arc applies the resolution over the configured connection string each time, never over one resolved for an earlier tenant. Contexts you register through Arc’s helpers (`AddDbContextWithConnectionString`, `AddReadOnlyDbContext`, discovery, or `IEntityFrameworkCoreBuilder.AddDbContext`) get this automatically. | Resolved part | SQL Server | PostgreSQL | SQLite | | --- | --- | --- | --- | | `ConnectionString` | Replaces the configured one | Replaces the configured one | Replaces the configured one | | `Database` | `Initial Catalog` | `Database` | `Data Source` (the file) | | `Identity.UserName` / `Password` | `User ID` / `Password` | `Username` / `Password` | Ignored | | `Identity.AccessToken` | `SqlConnection.AccessToken` (Entra ID) | Used as the password | Ignored | | `Schema` | Not applied | Not applied | Not applied | For passwordless Entra ID with a managed identity, you need no token at all. Resolve a connection string that carries `Authentication=Active Directory Managed Identity;User Id=`, and the SQL client signs in on its own. With `Microsoft.Data.SqlClient` 7.x, the `Active Directory` authentication modes need the `Microsoft.Data.SqlClient.Extensions.Azure` package. Do not combine an access token with a password SQL Server refuses an access token on a connection that also has a password or `Integrated Security`. Resolve one or the other. When opening fails to authenticate (SQL Server error 18456, PostgreSQL `28000` or `28P01`), Arc drops the cached resolution. That open still fails, and the next one resolves again. ## Run migrations as a different identity Resolution happens for `Runtime` by default. Wrap schema changes so your resolver can hand out an identity that may alter the schema: ```csharp using (ConnectionPurposeScope.Begin(ConnectionPurpose.Schema)) { await dbContext.Database.MigrateAsync(); } ``` The resolver receives `"purpose": "Schema"`, and a template can use `{purpose}`. ## Limits * The connection string the context reports is the one last applied. Pass connection strings through resolution rather than combining it with `Database.SetConnectionString`. * SQL Server change notifications for [observing](/arc/backend/csharp/entity-framework/observing/) open their own connection from the reported connection string, which carries no access token. # Entity mapping > Discover EF entity configurations through Arc context registration. The Arc provides a clean, organized way to configure your Entity Framework Core entities through the `IEntityTypeConfiguration` interface from Microsoft.EntityFrameworkCore. This approach separates entity configuration from your DbContext, making your code more maintainable. ## Overview Entity mapping in the Arc allows you to define how your entities are configured for Entity Framework Core in dedicated classes. These entity configurations are automatically discovered and applied when your DbContext is created, eliminating the need to override `OnModelCreating` in every DbContext that inherits from the [`BaseDbContext`](/arc/backend/csharp/entity-framework/base-db-context/). ## Creating Entity Configurations To create an entity configuration, implement the `IEntityTypeConfiguration` interface for your entity type: ```csharp using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; public class UserConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { // Configure the primary key builder.HasKey(u => u.Id); // Configure properties builder.Property(u => u.Name) .HasMaxLength(100) .IsRequired(); builder.Property(u => u.Email) .HasMaxLength(255) .IsRequired(); // Configure indexes builder.HasIndex(u => u.Email) .IsUnique(); // Configure relationships builder.HasMany(u => u.Orders) .WithOne(o => o.User) .HasForeignKey(o => o.UserId); } } ``` ## Automatic Discovery and Registration Entity configurations are automatically discovered and registered when your application starts. The Arc: 1. **Discovers all implementations** of `IEntityTypeConfiguration` in your application 2. **Registers them with dependency injection** so they can have dependencies injected 3. **Applies them automatically** during `OnModelCreating` in your DbContext This happens with `BaseDbContext` **and Arc context registration**, which supplies the entity registrar through the options’ application service provider. Inheritance alone does not supply that service. Model declaration fragment: ```csharp public class MyDbContext : BaseDbContext { public MyDbContext(DbContextOptions options) : base(options) { } public DbSet Users { get; set; } public DbSet Orders { get; set; } // No need to override OnModelCreating - entity maps are applied automatically! } ``` ## Dependency Injection Support Entity configurations support dependency injection, allowing you to inject services that might be needed for configuration: ```csharp public class UserConfiguration : IEntityTypeConfiguration { private readonly IConfiguration _configuration; public UserConfiguration(IConfiguration configuration) { _configuration = configuration; } public void Configure(EntityTypeBuilder builder) { builder.HasKey(u => u.Id); // Use injected configuration var maxNameLength = _configuration.GetValue("User:MaxNameLength", 100); builder.Property(u => u.Name) .HasMaxLength(maxNameLength) .IsRequired(); } } ``` ## Advanced Configuration Examples ### Complex Property Configuration ```csharp public class OrderConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.HasKey(o => o.Id); // Configure value objects builder.OwnsOne(o => o.ShippingAddress, address => { address.Property(a => a.Street).HasMaxLength(200); address.Property(a => a.City).HasMaxLength(100); address.Property(a => a.PostalCode).HasMaxLength(20); }); // Configure computed columns builder.Property(o => o.TotalAmount) .HasComputedColumnSql("[Quantity] * [UnitPrice]"); } } ``` For JSON metadata, mark the entity property with `[Json]` from `Cratis.Arc.EntityFrameworkCore.Json`; `BaseDbContext` discovers it through `ApplyJsonConversion`. There is no Arc `HasJsonConversion()` property extension. The computed-column SQL above is SQL Server-specific; adapt it for other providers. These mapping fragments assume your application’s entity declarations. ### Multiple Entity Configurations ```csharp public class ProductConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.HasKey(p => p.Id); builder.Property(p => p.Name) .HasMaxLength(200) .IsRequired(); builder.Property(p => p.Price) .HasPrecision(18, 2); // Table configuration builder.ToTable("Products", "Catalog"); // Soft delete configuration builder.HasQueryFilter(p => !p.IsDeleted); } } public class CategoryConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.HasKey(c => c.Id); builder.Property(c => c.Name) .HasMaxLength(100) .IsRequired(); // Self-referencing relationship builder.HasMany(c => c.Children) .WithOne(c => c.Parent) .HasForeignKey(c => c.ParentId); } } ``` ## Best Practices ### Organization * **One entity configuration per entity**: Keep each entity configuration focused on a single entity type * **Descriptive naming**: Use clear names like `UserConfiguration`, `OrderConfiguration`, etc. * **Logical grouping**: Place entity configurations in a dedicated folder (e.g., `Configuration/` or `Mapping/`) ### Configuration Guidelines * **Configure all aspects**: Include keys, properties, relationships, and constraints * **Be explicit**: Don’t rely on conventions for critical configurations * **Use meaningful names**: Configure table and column names explicitly when needed * **Document complex logic**: Add comments for complex mapping logic ### Performance Considerations * **Index configuration**: Configure indexes for frequently queried properties * **Query filters**: Use global query filters for soft delete patterns * **Relationship loading**: Configure loading behavior for relationships ## Integration with BaseDbContext When you inherit from `BaseDbContext`, entity configurations are automatically applied during model creation. The base class: 1. Discovers all `DbSet` properties on your DbContext 2. Looks for corresponding `IEntityTypeConfiguration` implementations 3. Applies the configurations during `OnModelCreating` Arc registration supplies the registrar needed for this discovery. Keep entity configuration dependencies suitable for cached EF models: do not use request-specific tenant state to shape a pooled context’s model. ## Migration Support Entity configurations determine the EF Core model used by migrations. When you change them: 1. Run `dotnet ef migrations add ` to create a migration 2. The migration will include all changes from your entity configurations 3. Run `dotnet ef database update` to apply the changes Ensure your design-time context creation supplies the same entity mappings and conversion options as runtime registration. A directly constructed context without the application service provider does not automatically discover maps. # Get started with Entity Framework Core > Add optional relational persistence to a standalone Arc application. When your Arc application needs relational storage, add `Cratis.Arc.EntityFrameworkCore`. It provides context registration, model conventions, and observation helpers. **Chronicle is not required**: commands can write through EF Core and queries can read the same database without event sourcing. ## Add the integration Start from a working [ASP.NET Core Arc host](/arc/backend/csharp/asp-net-core/) or [lightweight Arc host](/arc/backend/csharp/core/getting-started/), then install: ```bash dotnet add package Cratis.Arc.EntityFrameworkCore ``` The following is a startup fragment for an existing Arc builder, not a second complete host program. Import `Cratis.Arc` and `Cratis.Arc.EntityFrameworkCore`, plus your host’s builder namespace (`Microsoft.AspNetCore.Builder` for ASP.NET Core). ```csharp builder.AddCratisArc(configureBuilder: arc => { arc.WithEntityFrameworkCore(options => { options.ConnectionString = "Data Source=orders.db"; }); }); ``` This selects SQLite, registers observation services, and discovers public `BaseDbContext` subclasses. It does **not** create your tables or apply migrations. Use your application’s migration process before querying. ## Define a context Give domain values names before using them in the entity. `OrderId` cannot be accidentally substituted for an unrelated integer identifier, and `OrderDescription` keeps the field’s intent visible. Define these shared concepts once in the order feature, each in its own file: ```csharp using Cratis.Concepts; public record OrderId(int Value) : ConceptAs(Value) { public static readonly OrderId NotSet = new(0); public static implicit operator OrderId(int value) => new(value); } public record OrderDescription(string Value) : ConceptAs(Value) { public static readonly OrderDescription NotSet = new(string.Empty); public static implicit operator OrderDescription(string value) => new(value); } ``` These model declarations reuse those concepts and demonstrate the required options constructor: ```csharp using Cratis.Arc.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; public class OrdersDbContext(DbContextOptions options) : BaseDbContext(options) { public DbSet Orders => Set(); } public class Order { public required OrderId Id { get; set; } public required OrderDescription Description { get; set; } } ``` `BaseDbContext` applies concept value conversion, so the named values still map to primitive columns. Choose and test your application’s key-allocation strategy rather than treating `OrderId.NotSet` as a stored identity. See [concept mapping](/arc/backend/csharp/entity-framework/concept-as-conversion/). With a nonempty connection string and `AutoDiscoverDbContexts = true` (the default), Arc registers discovered contexts through pooled factories and exposes each context as a scoped service. `ReadOnlyDbContext` subclasses receive read-only registration. `[IgnoreAutoRegistration]` excludes a context. With an empty connection string, discovery returns without registering contexts. A registration checkpoint is that a service scope can resolve `OrdersDbContext` and `IDbContextFactory`. Successful registration alone does not prove the database is reachable or its schema is ready. ## Customize registration For a context that needs a different connection or options, disable discovery and register it explicitly. This is an alternative startup fragment: ```csharp builder.AddCratisArc(configureBuilder: arc => { arc.WithEntityFrameworkCore( configureOptions: options => { options.ConnectionString = "Data Source=orders.db"; options.AutoDiscoverDbContexts = false; }, configureEfCore: ef => { ef.AddDbContext((serviceProvider, options) => { options.EnableDetailedErrors(); }); }); }); ``` The callback takes **two arguments**, `IServiceProvider` and `DbContextOptionsBuilder`. Avoid sensitive-data logging in production. | Option | Default | Purpose | | --- | --- | --- | | `ConnectionString` | `""` | Shared connection for discovery and builder registration without an explicit connection | | `AutoDiscoverDbContexts` | `true` | Discover eligible contexts when the connection string is nonempty | | `JsonConverters` | Empty list | Append converters to EF’s JSON conversion options; see [JSON conversion](/arc/backend/csharp/entity-framework/json/#registering-custom-converters) | | `ConnectionResolution` | Nothing resolved | Resolve the connection per tenant; see [connection resolution](/arc/backend/csharp/entity-framework/connection-resolution/) | `ConnectionString`, `AutoDiscoverDbContexts` and `ConnectionResolution` also bind from the `Cratis:EntityFrameworkCore` configuration section; `configureOptions` runs after binding and wins. ## Direct service registration You can also use the registration helpers on `IServiceCollection`. This fragment assumes `services` belongs to your existing host: ```csharp services.AddEntityFrameworkCoreObservation(); services.AddDbContextWithConnectionString("Data Source=orders.db"); ``` Import `Cratis.Arc.EntityFrameworkCore.Observe` and `Microsoft.Extensions.DependencyInjection` as well as the context namespaces above. The helper adds observation interceptors when observation services are available at options creation. Register everything before building the service provider. Direct EF registration is not a replacement for Arc host activation. In particular, `Observe()` uses Arc’s initialized query context and service provider. Use the normal Arc bootstrap if you use Arc observation. ## Scope and provider limits The built-in connection detection supports SQLite, PostgreSQL, and SQL Server. See [automatic database hookup](/arc/backend/csharp/entity-framework/automatic-database-hookup/) for connection patterns and assembly scanning. Pooled factories capture the configured connection string. They **do not automatically select a database per Arc tenant**. Tenant isolation requires an application-designed, tested strategy; do not capture request-scoped tenant state in pooled options or assume MongoDB database naming applies to EF. Next, configure [entity mappings](/arc/backend/csharp/entity-framework/entity-mapping/), choose [read-only behavior](/arc/backend/csharp/entity-framework/read-only/), or add [observation](/arc/backend/csharp/entity-framework/observing/) after verifying ordinary database reads and writes. # Guid conversion > Distinguish Guid value conversion from migration column declarations. A .NET `Guid` and a SQL column declaration are different contracts. Arc’s Guid conversion changes the value sent to SQLite; it does not tune indexes, collations, or every EF provider. ## Model conversion `BaseDbContext` calls `ApplyGuidConversion` for relevant model types. For manual configuration, this is an `OnModelCreating` fragment with `Cratis.Arc.EntityFrameworkCore` and `Microsoft.EntityFrameworkCore` imported: ```csharp modelBuilder.Entity() .Property(customer => customer.Id) .AsGuid(Database.GetDatabaseType()); ``` Here `Customer.Id` is a `Guid` property and `Database` belongs to the context. | Database | What `AsGuid(databaseType)` adds | | --- | --- | | SQLite | A `Guid` → string converter using `ToString("D")`, with `Guid.Parse` on reads | | PostgreSQL | No additional conversion for a plain Guid; the EF provider supplies its mapping | | SQL Server | No additional conversion for a plain Guid; the EF provider supplies its mapping | For a Guid-backed concept, `AsGuid` delegates to `AsConcept`. SQLite uses string storage for that converter too. Ordinary `ConceptAs` identifiers remain valid in standalone Arc applications. ## Migration declarations `GuidColumn` and `AddGuidColumn` select these SQL type names independently of the model converter: | PostgreSQL | SQL Server | SQLite | | --- | --- | --- | | `UUID` | `UNIQUEIDENTIFIER` | `BLOB` | The SQLite migration helper therefore declares `BLOB` even though `AsGuid` supplies strings. Do not interpret the helper as a `CHAR(36)` declaration or as binary serialization. Review generated migrations and test existing-data compatibility before changing a schema or representation. Arc’s built-in database detection covers the three providers above. Other EF providers may have their own Guid support, but Arc does not supply MySQL/Oracle optimization or collation guarantees. See [common column types](/arc/backend/csharp/entity-framework/common-column-types/) and [concept conversion](/arc/backend/csharp/entity-framework/concept-as-conversion/) for related contracts. # JSON conversion > Store explicitly marked EF properties as JSON with separate conversion options. The JSON conversion feature provides automatic serialization and deserialization of complex objects to JSON format in the database. This cross-database approach allows you to store rich data structures while maintaining compatibility across different database providers. ## What it does With `BaseDbContext`, or an explicit `ApplyJsonConversion` call, Arc configures properties marked with `[Json]`. These model/startup fragments extend the host from [getting started](/arc/backend/csharp/entity-framework/getting-started/); they are not independent programs. 1. **Serialization**: Complex objects are automatically serialized to JSON when saving to the database 2. **Deserialization**: JSON data is automatically converted back to objects when loading from the database 3. **Cross-Provider Compatibility**: Uses the most appropriate JSON storage method for each database provider 4. **Concept Support**: Includes automatic support for Cratis concepts through the integrated converter factory This automatic configuration ensures that complex data structures are properly stored and retrieved while maintaining type safety and database compatibility. ## Why it’s important Using JSON conversion provides several key benefits: * **Rich Data Storage**: Store complex objects and collections without creating separate tables * **Schema Flexibility**: Easily evolve object structures without complex database migrations * **Cross-Database Compatibility**: Consistent JSON handling across different database providers * **Performance**: Reduces the need for complex joins when loading related data * **Type Safety**: Maintains strong typing while providing flexible storage ## Model Usage Your entity models can use complex properties by marking them with the `[Json]` attribute: ```csharp using Cratis.Arc.EntityFrameworkCore.Json; public class Customer { public Guid Id { get; set; } public string Name { get; set; } [Json] public IEnumerable
Addresses { get; set; } [Json] public CustomerPreferences Preferences { get; set; } } public class Address { public string Street { get; set; } public string City { get; set; } public string Country { get; set; } } ``` The conversion will automatically: * Store collections and complex objects as JSON in the database * Handle serialization of nested objects and collections * Support Cratis concepts within the JSON data * Maintain type safety when loading the data back The selected column types are PostgreSQL `jsonb`, SQL Server `nvarchar(max)`, and SQLite `text`. This is value conversion, not a promise that arbitrary nested-property LINQ expressions translate to server-side JSON operators. Test provider query support explicitly. JSON options are separate from the plain serializer used by `AsPoint()` / `AsLineString()` / `AsPolygon()`; do not apply both paths to one property. ## Default Converters `JsonConversionOptions` is pre-populated with all Arc default JSON converters so that common types work out of the box without any extra configuration: | Converter | Handles | | --- | --- | | `ComplexKeyDictionaryJsonConverterFactory` | Dictionaries with complex keys | | `PointJsonConverter`, `LineStringJsonConverter`, `PolygonJsonConverter` | Cratis geometry in GeoJSON format | | `ConceptAsJsonConverterFactory` | Cratis `ConceptAs` value types | | `EnumerableConceptAsJsonConverterFactory` | `IEnumerable>` sequences | | `EnumConverterFactory` | Enum values (serialized as integers) | | `DateOnlyJsonConverter` | `System.DateOnly` | | `TimeOnlyJsonConverter` | `System.TimeOnly` | | `TypeJsonConverter` | `System.Type` | | `UriJsonConverter` | `System.Uri` | | `EnumerableModelWithIdToConceptOrPrimitiveEnumerableConverterFactory` | Enumerable model-with-id to concept/primitive | | `DerivedTypeJsonConverterFactory` | Added when `IDerivedTypes` is supplied; handles registered polymorphic types | ## Registering Custom Converters Some property types require a custom `JsonConverter` to round-trip correctly — for example, interface types, abstract base classes, or discriminated unions. Without a matching converter, `JsonSerializer` throws `NotSupportedException: Deserialization of interface or abstract types is not supported`. Register additional converters at startup through the `JsonConverters` list on `EntityFrameworkCoreOptions`. The converters are appended after the built-in Arc defaults: ```csharp builder.AddCratisArc(configureBuilder: arcBuilder => { arcBuilder.WithEntityFrameworkCore(options => { options.ConnectionString = "Data Source=store.db"; options.JsonConverters.Add(new MyRequestConverter()); }); }); ``` With the converter registered, an entity with an interface-typed `[Json]` property works correctly: ```csharp public interface IMyRequest { int Count { get; } } public sealed record DoThing(int Count) : IMyRequest; public class MyEntity { [Key] public Guid Id { get; set; } [Json] public required IMyRequest Request { get; set; } } ``` ### Writing a Custom Converter Implement `JsonConverter` where `T` is the interface or abstract type that needs to be handled: ```csharp using System.Text.Json; using System.Text.Json.Serialization; public class MyRequestConverter : JsonConverter { public override IMyRequest? Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using var doc = JsonDocument.ParseValue(ref reader); // Use a discriminator or well-known property to pick the concrete type var count = doc.RootElement.GetProperty("count").GetInt32(); return new DoThing(count); } public override void Write( Utf8JsonWriter writer, IMyRequest value, JsonSerializerOptions options) { writer.WriteStartObject(); writer.WriteNumber("count", value.Count); writer.WriteEndObject(); } } ``` ## Manual Configuration If you’re not using [`BaseDbContext`](/arc/backend/csharp/entity-framework/base-db-context/), apply JSON conversion in `OnModelCreating`. In addition to the JSON namespace below, import `Cratis.Arc.EntityFrameworkCore` and `Microsoft.EntityFrameworkCore`. ```csharp using Cratis.Arc.EntityFrameworkCore.Json; public class StoreDbContext(DbContextOptions options) : DbContext(options) { public DbSet Customers => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { var entityTypes = modelBuilder.Model.GetEntityTypes(); modelBuilder.ApplyJsonConversion(entityTypes, Database.GetDatabaseType()); base.OnModelCreating(modelBuilder); } } ``` To include custom converters in a manual setup, pass a `JsonConversionOptions` instance directly: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { var jsonOptions = new JsonConversionOptions(); jsonOptions.JsonSerializerOptions.Converters.Add(new MyRequestConverter()); var entityTypes = modelBuilder.Model.GetEntityTypes(); modelBuilder.ApplyJsonConversion(entityTypes, Database.GetDatabaseType(), jsonOptions); base.OnModelCreating(modelBuilder); } ``` > Note: When using [`BaseDbContext`](/arc/backend/csharp/entity-framework/base-db-context/) with `WithEntityFrameworkCore`, the `JsonConversionOptions` singleton is resolved automatically from DI — you only need to populate `EntityFrameworkCoreOptions.JsonConverters` at startup. ## How it works The conversion system uses reflection to: 1. Identify all properties in your entities that are marked with the `[Json]` attribute 2. Create appropriate value converters that handle serialization and deserialization 3. Configure value comparers for proper change tracking 4. Apply these converters to the Entity Framework model builder The conversion is handled by the `JsonConversion.ApplyJsonConversion()` extension method, which automatically discovers and configures all JSON properties in your model. # LineString conversion > Explicitly convert a route to a JSON string in an EF model. A route contains an ordered sequence of points. `AsLineString()` stores that sequence using plain JSON string conversion; `BaseDbContext` does not apply it automatically. ## Define and configure a route Model declaration: ```csharp using Cratis.Geospatial; public class DeliveryRoute { public int Id { get; set; } public required LineString Path { get; set; } } ``` In your context’s `OnModelCreating`, with `Cratis.Arc.EntityFrameworkCore` and `Microsoft.EntityFrameworkCore` imported: ```csharp modelBuilder.Entity().Property(route => route.Path).AsLineString(); ``` A construction fragment for use inside a method: ```csharp var route = new LineString([ new Point(-122.4194, 37.7749), new Point(-122.4185, 37.7750) ]); ``` The converter’s plain `System.Text.Json` output is: ```json {"Coordinates":[{"Longitude":-122.4194,"Latitude":37.7749},{"Longitude":-122.4185,"Latitude":37.775}]} ``` This is **not GeoJSON**. The type and converter do not enforce the minimum two points needed for a valid geographic line. Validate the sequence before persisting it. ## Storage and queries `AsLineString()` sets a value converter, not a SQL column type, spatial index, or structural value comparer. Do not assume record equality on coordinate arrays compares their contents, or that nested-coordinate LINQ expressions translate to SQL. Test any equality/change-tracking behavior your application depends on. There is no `LineStringColumn()` helper for `CreateTable`. The separate `AddLineStringColumn()` migration helper declares `geometry(LineString, 4326)` on PostgreSQL, `geography` on SQL Server, and `TEXT` on SQLite. It does not make the string converter compatible with native spatial columns. Use the [storage-path matrix](/arc/backend/csharp/entity-framework/point-conversion/#choose-one-storage-path) to choose between explicit string conversion, `[Json]`, and application-configured native spatial mapping. Continue with [Polygon conversion](/arc/backend/csharp/entity-framework/polygon-conversion/) for areas. # Add columns in migrations > Reference for Arc migration column helpers and provider-specific declarations. Arc’s column helpers let a migration select the corresponding database type for PostgreSQL, SQL Server, or SQLite. The mappings below show where those providers differ. These extension methods are similar to the [Common Column Types](/arc/backend/csharp/entity-framework/common-column-types/) used when creating tables, but are specifically designed for `AddColumn` operations in migrations. ## Available AddColumn Extension Methods The following methods extend `MigrationBuilder`. Import `Cratis.Arc.EntityFrameworkCore`; `AddJsonColumn` additionally requires `Cratis.Arc.EntityFrameworkCore.Json`. Examples are migration fragments using your application’s context and schema; they do not run independently. | Extension Method | Description | Supported Types | Parameters | | --- | --- | --- | --- | | `AddStringColumn()` | Adds a string column with appropriate database-specific type (VARCHAR/NVARCHAR/TEXT) | string | `name`, `table`, `maxLength` (int?, optional), `nullable` (bool, default: true), `defaultValue` (string?, optional), `schema` (string?, optional) | | `AddNumberColumn()` | Adds a numeric column with appropriate database-specific type for any numeric type | char, byte, sbyte, short, ushort, int, uint, long, ulong, float, double, decimal | `name`, `table`, `nullable` (bool, default: true), `defaultValue` (object?, optional), `schema` (string?, optional) | | `AddBoolColumn()` | Adds a boolean column with appropriate database-specific type (BOOLEAN/BIT/INTEGER) | bool | `name`, `table`, `nullable` (bool, default: true), `defaultValue` (bool, default: false), `schema` (string?, optional) | | `AddAutoIncrementColumn()` | Adds an auto-incrementing integer column with appropriate database-specific annotations | int | `name`, `table`, `schema` (string?, optional) | | `AddGuidColumn()` | Adds a GUID/UUID column with appropriate database-specific type (UUID/UNIQUEIDENTIFIER/BLOB) | Guid | `name`, `table`, `nullable` (bool, default: true), `schema` (string?, optional) | | `AddDateTimeOffsetColumn()` | Adds a DateTimeOffset column with appropriate database-specific type (TIMESTAMPTZ/DATETIMEOFFSET/TEXT) | DateTimeOffset | `name`, `table`, `nullable` (bool, default: true), `schema` (string?, optional) | | `AddPointColumn()` | Declares a spatial/text column; not a model converter | Point | `name`, `table`, `nullable: true`, `schema` | | `AddLineStringColumn()` | Declares a spatial/text column; not a model converter | LineString | `name`, `table`, `nullable: true`, `schema` | | `AddPolygonColumn()` | Declares a spatial/text column; not a model converter | Polygon | `name`, `table`, `nullable: true`, `schema` | | `AddJsonColumn()` | Declares a non-nullable JSON column | JSON property type | `name`, `table`, `schema` | Geometry helpers select `geometry(Point, 4326)`, `geometry(LineString, 4326)`, or `geometry(Polygon, 4326)` on PostgreSQL; `geography` on SQL Server; and `TEXT` on SQLite. They do not install spatial provider mappings or make plain JSON string converters compatible with native spatial columns. See the [storage-path matrix](/arc/backend/csharp/entity-framework/point-conversion/#choose-one-storage-path). On PostgreSQL, `AddJsonColumn()` selects `jsonb` and adds a JSON CHECK constraint and a GIN index using `jsonb_path_ops`. The column, constraint, and index are emitted as separate, terminated commands. The index uses a `::jsonb` expression, so an explicitly overridden `text` or `json` store type also works. Generated constraint and index names are quoted; names exceeding PostgreSQL’s 63-byte identifier limit are shortened with a stable hash suffix. Adding non-nullable columns to populated tables may require a default or a staged backfill. Verify the generated SQL, model compatibility, permissions, and provider restrictions before applying any migration. ## Database-Specific Type Mappings The extension methods automatically select the appropriate SQL type based on the database provider. See [Common Column Types](/arc/backend/csharp/entity-framework/common-column-types/) for detailed type mappings for each database. ## Usage Examples ### Adding String Columns ```csharp [DbContext(typeof(MyDbContext))] [Migration($"MyContext_{nameof(v1_1_0)}")] public class v1_1_0 : Migration { protected override void Up(MigrationBuilder migrationBuilder) { // Add string column with length limit migrationBuilder.AddStringColumn( name: "Email", table: "Users", maxLength: 255, nullable: false); // Add unlimited text column with default value migrationBuilder.AddStringColumn( name: "Description", table: "Products", defaultValue: "No description provided"); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn(name: "Email", table: "Users"); migrationBuilder.DropColumn(name: "Description", table: "Products"); } } ``` ### Adding Numeric Columns ```csharp protected override void Up(MigrationBuilder migrationBuilder) { // Add integer column migrationBuilder.AddNumberColumn( name: "ViewCount", table: "Articles", defaultValue: 0, nullable: false); // Add decimal column for currency migrationBuilder.AddNumberColumn( name: "Price", table: "Products", nullable: true); // Add double column for ratings migrationBuilder.AddNumberColumn( name: "Rating", table: "Reviews", defaultValue: 0.0, nullable: false); } ``` ### Adding Boolean Columns ```csharp protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddBoolColumn( name: "IsActive", table: "Users", defaultValue: true, nullable: false); migrationBuilder.AddBoolColumn( name: "IsDeleted", table: "Orders", defaultValue: false); } ``` ### Adding GUID Columns ```csharp protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddGuidColumn( name: "ExternalId", table: "Users", nullable: false); migrationBuilder.AddGuidColumn( name: "CorrelationId", table: "Events", nullable: true); } ``` ### Adding DateTime Columns ```csharp protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddDateTimeOffsetColumn( name: "LastLoginAt", table: "Users", nullable: true); migrationBuilder.AddDateTimeOffsetColumn( name: "CreatedAt", table: "Orders", nullable: false); } ``` ### Adding Auto-Increment Columns ```csharp protected override void Up(MigrationBuilder migrationBuilder) { // Add auto-increment column (useful for adding sequence numbers to existing tables) migrationBuilder.AddAutoIncrementColumn( name: "SequenceNumber", table: "AuditLog"); } ``` ### Using Schemas For databases that support schemas (PostgreSQL, SQL Server), you can specify the schema: ```csharp protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddStringColumn( name: "Department", table: "Employees", schema: "hr", maxLength: 100, nullable: false); } ``` ## Benefits Using these extension methods provides several advantages: * **Database Portability**: Write migrations once and support multiple database providers * **Type Safety**: Strongly typed methods reduce errors * **Consistency**: Ensures consistent type mapping across your application * **Simplified Syntax**: Less boilerplate code compared to manually specifying types * **Maintainability**: Centralized type mapping logic makes updates easier ## See Also * [Common Column Types](/arc/backend/csharp/entity-framework/common-column-types/) - For creating columns when defining new tables * [Base DbContext](/arc/backend/csharp/entity-framework/base-db-context/) - Learn about the base DbContext implementation * [Automatic Database Hookup](/arc/backend/csharp/entity-framework/automatic-database-hookup/) - Automatic database configuration # Observe a DbSet > Observe EF query results with explicit provider prerequisites and lifecycle limits. Once ordinary EF reads work, observation can refresh a result when the underlying table changes. Arc combines in-process SaveChanges interception with optional provider notifications. **SQLite does not poll external writes.** ## Configure the host `WithEntityFrameworkCore()` registers observation services. For explicit registration in an existing Arc host, import `Cratis.Arc.EntityFrameworkCore`, `Cratis.Arc.EntityFrameworkCore.Observe`, `Microsoft.EntityFrameworkCore`, and `Microsoft.Extensions.DependencyInjection`, then use this startup fragment: ```csharp services.AddEntityFrameworkCoreObservation(); services.AddDbContextWithConnectionString("Data Source=orders.db"); ``` Arc’s context helpers add the observation interceptor when those services are available. For ordinary EF registration, add `.AddObservation(serviceProvider)` in the two-argument options callback. The context must also be resolvable from a fresh scope: observation resolves its concrete type for initial and subsequent queries. Keep the normal Arc registration **and activation**. `Observe()` accesses Arc’s initialized service provider and current query context; adding EF services alone is not a complete observation host. ## Observe results These are query-method fragments using your application’s context/entities. Here `Customer.Id` and `customerId` use the application’s `CustomerId : ConceptAs` domain identity. Import `Microsoft.EntityFrameworkCore` and `System.Reactive.Linq`. ```csharp var orders = dbContext.Orders.Observe(order => order.IsPending); var customer = dbContext.Customers.ObserveSingle(customer => customer.Email == email); var byId = dbContext.Customers.ObserveById(customerId); ``` `Observe()` returns `ISubject>`. `ObserveSingle()` and `ObserveById()` return `ISubject`. When no entity matches, they emit `null` and keep the subscription open: initially (a late subscriber replays that `null`), after a delete, and after an update moves the entity out of the filter. This matches MongoDB’s single-document observation. Treat `null` as “not found” on the client. Observation supports EF Core indexer primary keys on named shared-type sets such as `dbContext.Set>("PropertyBag")`. `ObserveById()` filters on a mapped public CLR `Id` property when one exists; otherwise it falls back to a single, non-shadow EF primary key. Collection and single-entity observation use that primary key for identity, falling back to a mapped public CLR `Id` for composite, keyless, or shadow-key entities. Shadow-only keys without a usable CLR `Id` fail at observation setup. To customize loading, use the second callback: ```csharp var orders = dbContext.Orders.Observe( order => order.IsPending, configure: set => set.Include(order => order.Lines)); ``` Arc applies the filter and current query-context paging/sorting to the configured query, including sorting by mapped indexer properties. Initial querying is synchronous and can throw before a subject is returned. Later re-query errors are logged, not sent as `OnError` to subscribers. ### Compose a primary observation with auxiliary sources When a query combines observations from several DbSets, client paging and sorting belong to the primary source. Existing composed queries must opt in on each auxiliary observation to get this behavior; otherwise those observations still use the client query context. Pass `ignoreQueryContext: true` to an auxiliary observation to read **all** entities matching its filter without client paging or sorting. This also prevents that source from writing the query’s `TotalItems`, both at startup and on changes: ```csharp var primary = dbContext.Orders.Observe(); var auxiliary = dbContext.Customers.Observe(ignoreQueryContext: true); var selected = dbContext.Customers.ObserveSingle(customer => customer.Email == email, ignoreQueryContext: true); var byId = dbContext.Customers.ObserveById(customerId, ignoreQueryContext: true); ``` These query-method fragments assume the `dbContext`, `email`, and `customerId` from the examples above. The option is available on filtered and filterless `Observe` and `ObserveSingle` calls, as well as `ObserveById`; the optional `configure` callback still works. The primary observation keeps its usual paging, sorting, and `TotalItems` behavior; **you** decide which source is primary. `TotalItems` describes the primary DbSet’s filtered count, not the size of an arbitrary result you derive by combining sources. If your composed result has different pagination semantics, compute its count explicitly rather than treating a DbSet’s count as the composed count. ## Provider capabilities | Provider | In-process writes | External writes and prerequisites | | --- | --- | --- | | SQLite | SaveChanges through contexts with Arc’s observation interceptor | No external notifier; `StartListening` is a no-op. No polling fallback. | | PostgreSQL | Same interception path | `LISTEN/NOTIFY`; Arc attempts to create a function and table trigger. The database user needs suitable permissions, or an administrator must supply a compatible trigger/channel. | | SQL Server | Same interception path | `SqlDependency` with Service Broker enabled and query-notification permissions/compatible queries. Arc attempts to enable Service Broker. No general polling fallback. | Enabling Service Broker can terminate connections When Service Broker is disabled, Arc attempts `ALTER DATABASE [databaseName] SET ENABLE_BROKER WITH ROLLBACK IMMEDIATE`. With sufficient permissions, this operation can terminate other connections and roll back their transactions. Pre-provision Service Broker through a controlled administrative maintenance/deployment procedure and use least-privilege application credentials. Do not grant `ALTER DATABASE` merely to make `Observe()` initialize. A PostgreSQL listener can start even when trigger creation fails. That alone does not establish that external changes are observable. Notifier setup failures are logged and observation can continue with in-process notifications only. Monitor logs and verify an external write in a test database before relying on cross-process updates. Notifications cause a query refresh; they are not a durable change log. Do not assume every intermediate state is delivered, that raw/bulk SQL passes through SaveChanges interception, or that changes to every included relationship independently trigger the root-table observation. ## Lifetime and disposal Arc’s streaming transports dispose their observer subscriptions when clients disconnect; they do **not** complete the EF subject. EF producer cleanup is tied to subject completion, so returning a raw EF subject does not give its producer a disconnect-driven lifetime. An HTTP snapshot, including `waitForFirstResult=true`, does not close this ownership gap. Before exposing EF observation through an observable query, establish an application-owned producer lifetime and test disconnects, concurrent clients, and shutdown. Do not complete a shared subject just because one client leaves: that would stop other clients too. For a subject you own directly, retain both the subject and subscription and **complete the subject** when finished. Disposing the Rx subscription alone does not stop this EF observation implementation. Lifecycle fragment inside an asynchronous method: ```csharp var subject = dbContext.Orders.Observe(order => order.IsPending); using var subscription = subject.Subscribe(orders => Console.WriteLine(orders.Count())); try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } finally { subject.OnCompleted(); } ``` Completion initiates notifier/interceptor cleanup; it is not an awaitable guarantee that notifier disposal has finished. Test cleanup against concurrent notifications with your provider. This lifecycle differs from [MongoDB collection observation](/arc/backend/csharp/mongodb/observing-collections/), whose subject tracks subscriber disposal. ## Scope and isolation Observation recreates scopes for reads. Arc’s pooled EF registrations keep the configured connection string; they do not infer per-tenant databases from the current tenant. Validate your application’s tenant strategy across initial reads, notification callbacks, and pooled reuse before using observation in a multi-tenant application. Continue with [read-only contexts](/arc/backend/csharp/entity-framework/read-only/) and [registration](/arc/backend/csharp/entity-framework/getting-started/). # Point conversion > Store a Cratis Point explicitly without confusing JSON conversion and spatial column types. To store a location, first choose its representation. Arc’s `AsPoint()` converts a `Cratis.Geospatial.Point` to a JSON **string**; it does not install a native spatial provider or select a JSON SQL column type. `BaseDbContext` does not call it automatically. ## Configure string conversion These are complete model/context declarations. This example derives from plain `DbContext`, which Arc’s automatic discovery does not discover. Register it explicitly in your [configured Arc host](/arc/backend/csharp/entity-framework/getting-started/) and apply your schema separately. ```csharp using Cratis.Arc.EntityFrameworkCore; using Cratis.Geospatial; using Microsoft.EntityFrameworkCore; public class Store { public int Id { get; set; } public required Point Location { get; set; } } public class StoreDbContext(DbContextOptions options) : DbContext(options) { public DbSet Stores => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().Property(store => store.Location).AsPoint(); base.OnModelCreating(modelBuilder); } } ``` After declaring the context, add this startup fragment, where `services` is the host’s `IServiceCollection`: ```csharp using Cratis.Arc.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; services.AddDbContextWithConnectionString("Data Source=store.db"); ``` `AsPoint()` takes no database argument. It uses plain `System.Text.Json`, not MongoDB’s GeoJSON serializer. For `new Point(Longitude: -122.4194, Latitude: 37.7749)`, the converted value is: ```json {"Longitude":-122.4194,"Latitude":37.7749} ``` The converter does not validate coordinate ranges. Validate longitude/latitude in your application. EF normally handles a null property without passing it through the value converter; choose property and column nullability consistently. ## Choose one storage path | Path | Configuration | Storage contract | | --- | --- | --- | | String conversion | Explicit `AsPoint()`, `AsLineString()`, or `AsPolygon()` | Plain JSON string; SQL type comes from the EF string mapping or your explicit configuration | | JSON property | `[Json]` with `BaseDbContext` or `ApplyJsonConversion` | JSON conversion options and provider-specific JSON column mapping; see [JSON conversion](/arc/backend/csharp/entity-framework/json/) | | Spatial migration | `AddPointColumn`, `AddLineStringColumn`, `AddPolygonColumn` | Spatial SQL type declarations on PostgreSQL/SQL Server, text on SQLite; not a geometry value converter | Do not apply both string and `[Json]` conversion to the same property. Their serializer options are separate. Neither path promises translated distance, intersection, or nested-coordinate LINQ queries. ## Migration usage Arc has **no `PointColumn()` helper for `CreateTable`**. A string-converted point can use an ordinary string column, aligned with your model mapping. The separate `MigrationBuilder.AddPointColumn` method selects: | PostgreSQL | SQL Server | SQLite | | --- | --- | --- | | `geometry(Point, 4326)` | `geography` | `TEXT` | This is a schema helper, **not a complete native spatial recipe**. Pairing its PostgreSQL/SQL Server columns with `AsPoint()` is not supported by an automatic adapter. Native spatial usage requires a compatible provider mapping, database prerequisites (such as PostGIS), migrations, and round-trip tests designed by your application. See [adding columns](/arc/backend/csharp/entity-framework/migrations-add-columns/), [LineString conversion](/arc/backend/csharp/entity-framework/linestring-conversion/), and [Polygon conversion](/arc/backend/csharp/entity-framework/polygon-conversion/). # Polygon conversion > Explicitly convert geographic boundaries to JSON strings and understand validation limits. A polygon represents an exterior boundary and optional holes. Arc’s `AsPolygon()` converts the record to a plain JSON string. It is not automatically applied by `BaseDbContext` and is not native spatial mapping. ## Define and configure an area Model declaration: ```csharp using Cratis.Geospatial; public class ServiceArea { public int Id { get; set; } public required Polygon Boundary { get; set; } } ``` Inside `OnModelCreating`, with `Cratis.Arc.EntityFrameworkCore` and `Microsoft.EntityFrameworkCore` imported: ```csharp modelBuilder.Entity().Property(area => area.Boundary).AsPolygon(); ``` Construction fragment inside a method: ```csharp var boundary = new Polygon( new LinearRing([ new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(0, 0) ]), Holes: []); ``` Plain JSON conversion writes `Shell` and `Holes`; rings contain `Coordinates` arrays of objects with `Longitude` and `Latitude`. It does not write GeoJSON’s `type` and nested numeric coordinate arrays. ## Validate boundaries yourself Neither the Fundamentals records nor `AsPolygon()` enforce ring closure, minimum point count, winding, or freedom from self-intersection. Your application must validate the geometry it requires. Use closed rings with at least three distinct vertices and a repeated closing point when targeting GeoJSON-compatible storage. The converter does not add a structural value comparer. Replace values deliberately and test change tracking when arrays or rings are modified. Do not promise SQL translation for containment or intersection from string conversion alone. ## Migrations There is no `PolygonColumn()` helper for `CreateTable`. The distinct `AddPolygonColumn()` helper selects `geometry(Polygon, 4326)` on PostgreSQL, `geography` on SQL Server, and `TEXT` on SQLite. Those declarations do not install a compatible value converter or database spatial extension. Choose a representation using the [storage-path matrix](/arc/backend/csharp/entity-framework/point-conversion/#choose-one-storage-path). See [JSON conversion](/arc/backend/csharp/entity-framework/json/) for `[Json]` properties and [adding columns](/arc/backend/csharp/entity-framework/migrations-add-columns/) for migration helpers. # Property extensions > Reference for Arc EF PropertyBuilder value converters. Import `Cratis.Arc.EntityFrameworkCore` for these extensions on EF’s `PropertyBuilder`. ## AsGuid() `AsGuid(DatabaseType databaseType)` adds a Guid/string converter for SQLite using the `D` format and `Guid.Parse`. For plain Guid properties on PostgreSQL and SQL Server, it leaves the EF provider mapping unchanged. For concept properties it delegates to `AsConcept`. `BaseDbContext` applies Guid conversion to relevant model types. Manual `OnModelCreating` fragment for a `Customer.Id` Guid property: ```csharp modelBuilder.Entity().Property(customer => customer.Id).AsGuid(Database.GetDatabaseType()); ``` This does not specify `CHAR(36)`, collation, or indexes. [Guid conversion](/arc/backend/csharp/entity-framework/guid-conversion/) explains the distinction from migration column types. ## AsConcept() `AsConcept(DatabaseType databaseType)` unwraps `ConceptAs` to its primitive value and reconstructs the concept on reads. It configures a concept value comparer and does nothing for non-concept properties. Guid concepts use string conversion for SQLite. Manual model-configuration fragment: ```csharp modelBuilder.Entity().Property(customer => customer.Id).AsConcept(Database.GetDatabaseType()); ``` Here `Customer.Id` is a concept property. Use records, not classes, for concept declarations, and nullable concept references for optional values. See [concept conversion](/arc/backend/csharp/entity-framework/concept-as-conversion/). `BaseDbContext` already applies this conversion for relevant model types. ## Geometry conversions | Method | Model type | Provider value | | --- | --- | --- | | `AsPoint()` | `Cratis.Geospatial.Point` | Plain JSON string | | `AsLineString()` | `Cratis.Geospatial.LineString` | Plain JSON string | | `AsPolygon()` | `Cratis.Geospatial.Polygon` | Plain JSON string | These methods take **no database argument** and are **not applied automatically** by `BaseDbContext`. They add a value converter only, without geometry validation, spatial query translation, SQL type selection, or a structural value comparer. Use the [geometry storage-path matrix](/arc/backend/csharp/entity-framework/point-conversion/#choose-one-storage-path) before combining conversions and migrations. `[Json]` is a separate property-attribute path with its own serializer options; it is not a synonym for these extensions. # Read-only DbContext > Configure query-oriented contexts while keeping database permissions as the write boundary. For query code, no-tracking reads and accidental-write guards express intent. Arc offers both a base class and registration helpers, but they are **not interchangeable security boundaries**. ## Define a query context Model declaration fragment; supply the `Customer` entity: ```csharp using Cratis.Arc.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; public class StoreDbContext(DbContextOptions options) : ReadOnlyDbContext(options) { public DbSet Customers => Set(); } ``` Register it through `WithEntityFrameworkCore` discovery or this explicit service-registration fragment: ```csharp services.AddReadOnlyDbContextWithConnectionString("Data Source=store.db"); ``` Import `Microsoft.Extensions.DependencyInjection` for service registration. ## Inheritance versus registration | Mechanism | Behavior | | --- | --- | | `ReadOnlyDbContext` inheritance | Includes `BaseDbContext` conversions; overrides `SaveChanges()` and `SaveChangesAsync(CancellationToken)` to throw; enables eager loading for ordinary navigations | | `AddReadOnlyDbContext` | Registers a pooled factory and scoped context; configures `NoTracking` and a SaveChanges interceptor; accepts any EF `DbContext` subclass | | `AddReadOnlyDbContextWithConnectionString` | Adds provider detection to read-only registration; requires a `BaseDbContext` subclass | Inheritance alone does not override the Boolean `acceptAllChangesOnSuccess` SaveChanges overloads. Registration’s interceptor protects the SaveChanges path but does not prohibit raw SQL, `ExecuteUpdate`, `ExecuteDelete`, or writes through another context. Use database credentials with read-only permissions when preventing writes is a requirement. ## Automatic eager loading `ReadOnlyDbContext` marks navigations returned by EF’s `GetNavigations()` as auto-included. Choose purpose-built models and inspect generated queries rather than assuming every relationship is cheap. Skip navigations are not included by that loop. To disable this behavior globally, add this member to your derived context: ```csharp protected override bool IsEagerLoadingEnabled => false; ``` To skip auto-includes for an individual query: ```csharp var customers = await context.Customers.IgnoreAutoIncludes().ToListAsync(); ``` To override a particular navigation, configure it **after** the base call: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity().Navigation(customer => customer.Orders).AutoInclude(false); } ``` These are context/query fragments using your application’s entities. EF’s own rules for owned navigations still apply. ## Assembly registration Assembly scanning selects exported, nonabstract `ReadOnlyDbContext` subclasses and excludes `[IgnoreAutoRegistration]` types. It does not discover every arbitrary `DbContext`. ```csharp services.AddReadModelDbContextsWithConnectionStringFromAssemblies( "Data Source=store.db", (serviceProvider, options) => options.EnableDetailedErrors(), typeof(StoreDbContext).Assembly); ``` See [automatic database hookup](/arc/backend/csharp/entity-framework/automatic-database-hookup/) for pooling and tenant limitations, then [observation](/arc/backend/csharp/entity-framework/observing/) for live query results. # Upserts > Choose tracked persistence or an immediate, atomic provider-native upsert. `Cratis.Arc.EntityFrameworkCore` provides two upsert APIs on `DbSet`. Both match the complete primary key configured in the EF model. ## Tracked upserts `await context.Items.Upsert(item)` keeps the existing tracked behavior: it finds an existing entity, copies values into its tracked instance, or marks a new entity as added. Call `SaveChangesAsync` to persist it. This is a read followed by a write, not an atomic database upsert; concurrent inserts can still conflict. ## Native upserts `await context.Items.UpsertAsync(item, cancellationToken)` executes one parameterized SQL statement on supported relational mappings: | Provider | Statement | | --- | --- | | SQL Server | `MERGE ... WITH (HOLDLOCK)` | | PostgreSQL | `INSERT ... ON CONFLICT ... DO UPDATE` | | SQLite | `INSERT ... ON CONFLICT ... DO UPDATE` | The primary-key match and write are atomic, including concurrent inserts of the same key. Retrying the same values updates that row rather than adding another. A key-only entity uses a no-op on conflict instead of an empty update clause. Deadlocks, unrelated unique constraints, and other database errors still propagate; this API does not introduce an automatic retry policy. Table, schema, column, and key names come from EF metadata and are quoted by the provider. Values are parameters using EF’s relational type mappings, including configured value converters. Null values remain null. Native execution uses the context’s connection and current transaction and honors the cancellation token. ### Change tracking and fallback Native execution is immediate: **do not call `SaveChangesAsync` to persist the upsert**. It does not attach the supplied entity, reconcile already tracked instances, or refresh store-generated properties. Reload previously tracked instances before relying on their values. Pending tracked changes are not flushed. Use application-assigned keys and single-table scalar mappings for native execution. Other providers, unset generated keys, SQL Server identity columns, navigations (including owned and many-to-many relationships), shadow or complex properties, inheritance, and multi-table mappings fall back to the tracked upsert and **require `SaveChangesAsync`**. Properties whose generated value is still unset also use the tracked fallback so EF can apply database defaults. The fallback does not provide the native path’s concurrency guarantee. Choose the tracked `Upsert` explicitly when consistent deferred persistence is required. # Generating a Screenplay > Arc reads your application's source and writes the event model it already describes as a .play file; this page says what the file contains and what it omits. An [event model](/event-modeling/) is the picture of your system: which commands change state, which events they produce, which read models those events build, and which reactors turn one thing into another. Teams draw it on a whiteboard at the start, and then the code moves on without it. Six months later the picture is fiction and nobody trusts it enough to open. **Screenplay** is a small language for writing that picture down as text — a `.play` file — so it can live in the repository next to the code it describes. Arc can *generate* one from your application’s source. The model stops being something you maintain by hand and becomes something you regenerate, the same way the [TypeScript proxies](/arc/understanding-the-proxy-boundary/) are regenerated rather than hand-written. ## Generated from source, not from a running system The generator reads a Roslyn compilation of your C# — the same thing the compiler sees. It never starts your application, connects to Chronicle, or looks at a deployed instance. That choice is what makes the output useful: * **It works from a checkout, not a live store.** You still need the CLI, a compatible .NET/MSBuild environment, restored project dependencies, and the build inputs required to obtain a meaningful compilation. No running database or Chronicle connection is needed. * **It is diffable in a pull request.** A commit that adds a command shows up as a few lines added to the `.play`. Reviewers see the model change next to the code change, and “did this alter the event model?” becomes a question the diff answers. * **It is reproducible.** The same source always produces byte-identical output. Everything is ordered explicitly rather than by whatever order symbols happened to arrive in, so regenerating in CI and failing on a diff is a viable check. A runtime-derived model would answer a different question — what one deployed instance looks like right now — and would vary with configuration and environment. There is one model, and its source of truth is your source code. ``` Roslyn compilation anything it cannot express C# source — commands, events, read models, reactors analysis application model .play document diagnostics ``` ## Running it The generator is used through the Cratis CLI, which ships separately from Arc: ```shell cratis screenplay generate ./MyApp/MyApp.csproj --file MyApp.play ``` Point it at a project or a solution. A solution is one application rather than several, so its projects are read together into a single document — see [an application written as several projects](#an-application-written-as-several-projects). Anything the generator could not express is reported on the way out, and a run that reports an error exits non-zero — so a document nothing trustworthy went into never quietly looks fine. What the CLI has to do before it hands the source over is covered in [what the generator expects of its host](#what-the-generator-expects-of-its-host). Because output is reproducible, a CI step can regenerate and fail when the committed `.play` no longer matches the source. ## Nothing is dropped silently The gap between “what C# can say” and “what Screenplay can say” is real, and the worst thing a generator can do is paper over it. Every construct that cannot be expressed is reported as a located diagnostic — a stable code, a message you can act on, and where it came from — rather than quietly disappearing. ```text Warning SP0019: The query 'Raw' returns 'IActionResult', which says how the result is transported rather than what it is, so the query was left out (Library.Messaging.Feed) ``` Diagnostics come in three severities. **Information** describes a limitation without failing generation. **Warning** means something was left out or standalone generation found an unexpected semantic binding error (`SP0056`). Embedded generation reports `SP0056` as Information so a generator limitation cannot break your application’s build. **Error** means the document should not be trusted at all — either because the generator produced something the language rejects, or because nothing at all was recovered from source the compiler accepted. ## What the generator expects of its host The compatibility generator accepts either a Roslyn `Compilation` or a Generation `DotNetProjectCompilation`. It never opens a project file or loads a workspace, which is what lets it be driven from a CLI, from a specification, or from an editor. `DotNetProjectCompilation` adds the host-owned project role, authored syntax trees, and stable source-path context needed by neutral source adapters; the established compilation-only overloads remain available and produce the same `.play` bytes. The other side of that bargain is that **assembling the compilation the way a real build would is the caller’s job** — the generator reads what it is handed and cannot tell a missing type from a type that was never written. Distinguish **workspace source generators** from **MSBuild-generated inputs**. `GetCompilationAsync` belongs to Roslyn `Project`, not `MSBuildWorkspace`. A workspace project compilation can already contain source-generator output; do not run the same generators over it again unconditionally. Arc’s CLI uses `project.GetCompilationAsync(cancellationToken)` and separately restores missing resource sources/framework references where needed. MSBuild/custom-tool output such as resource designer files is not interchangeable with Roslyn generator output. Arc’s TypeScript proxy generator is a post-build executable, not a C# source generator. For an already loaded workspace `Project`, the host fragment is: ```csharp using Microsoft.CodeAnalysis; static Task GetCompilation(Project project, CancellationToken cancellationToken) => project.GetCompilationAsync(cancellationToken); ``` Handle a null compilation and inspect diagnostics before passing it to Screenplay. Loading the workspace, restoring packages, and supplying missing build-generated files remain host responsibilities. If you intentionally construct a **raw C# compilation outside the workspace compilation path**, run the selected generators once. This alternative fragment assumes `project` supplies the matching analyzer references and parse options, and `compilation` has not already received those generated trees: ```csharp using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; static Compilation RunGenerators(Project project, Compilation compilation) { var driver = CSharpGeneratorDriver.Create( generators: project.AnalyzerReferences .SelectMany(reference => reference.GetGenerators(LanguageNames.CSharp)), parseOptions: (CSharpParseOptions)project.ParseOptions!); driver.RunGeneratorsAndUpdateCompilation(compilation, out var generated, out var diagnostics); foreach (var diagnostic in diagnostics) { Console.Error.WriteLine(diagnostic); } return generated; } ``` `AnalyzerReference.GetGenerators` already returns `ISourceGenerator` values. `AsSourceGenerator` converts an `IIncrementalGenerator`; applying it to those values does not compile. Real custom hosts must also supply any generator-required additional texts and analyzer-config options, and decide how diagnostics affect their exit status. This fragment is not a complete replacement for the CLI loader. ### What happens when it does not Nothing is hidden, and nothing correct is thrown away. A compilation carrying errors is still analyzed, and `SP0024` says what happened — how many errors there were, the first of them, and how many artifacts came through anyway: ```text Warning SP0024: The source did not compile - 607 error(s), the first being 'The name 'AccountsMessages' does not exist in the current context'. 341 artifact(s) were recovered anyway, 341 of them from a declaration no error sits inside, so the document describes those exactly as the source states them - a missing type named like 'SomethingMessages' or a designer class usually means the compilation was handed over without the compile items a build generates (Accounts) ``` Its **severity is decided rather than fixed**, because “the source did not compile” covers two outcomes that could not be further apart: * **Warning** when at least one artifact was recovered from a declaration no compilation error sits inside. Those artifacts are described exactly as their source states them whatever failed elsewhere, so the run is successful and the document is worth keeping. A compilation missing its generated symbols lands here — the errors sit in code that declares no artifact, and the model is unaffected. * **Error** when none were, either because nothing was recovered at all or because every declaration something came out of is one the compiler could not make sense of. There is then no part of the document a reader could trust, and a host following the contract exits non-zero. A count is used rather than a proportion deliberately: any threshold would make the same recovery pass for a large application and fail for a small one, and zero is the only number that means recovery was *prevented* rather than merely dented. Either way the document is written out, so what was recovered can be read. ### An application written as several projects Nothing says an application is one project. A layered one puts its contracts in a project of their own; a host sitting beside the bounded contexts it serves is two projects at least — and no single one of them describes the application. Pointed at any one, the generator would describe half of it and refer to events it never introduced. So `Generate` takes a list of compilations as well as a single one, and a host generating from a solution hands over one per project: ```csharp var result = generator.Generate([contracts, application, host], options); ``` A host that also runs neutral adapters should retain the project metadata instead of reducing each project to its compilation: ```csharp IReadOnlyList projects = LoadProjects(); var result = generator.Generate(projects, options); ``` This project-aware compatibility overload intentionally delegates to the same established generator. The source context is carried for the neutral adapter path, not used to reinterpret legacy placement. What comes back is one document rather than one per project, because the boundaries between projects are a build concern rather than something the model has: * A namespace two projects declare into is **one slice**, whichever project each artifact sits in. * A concept is **declared once**, however many projects refer to it. * An event a sibling project declares is one the application **has**, so it is declared like anything else — not imported the way an event from a referenced package outside the application is. Paths stay readable because they are written relative to the directory all the projects sit under rather than to each project’s own root, so every path opens with the project it belongs to — `Library/Shipping/Dispatching/Dispatching.cs` beside `Library.Contracts/Ordering/Placing/Placing.cs`. Projects checked out in unrelated places share no such directory; each one’s paths then fall back to its own root, and `SP0038` says so, because two files can then come out as the same path. The order the projects arrive in never reaches the document. Nothing decides what order a host enumerates a solution in, so they are sorted by assembly name before anything is read and the same projects always print the same bytes. Where that order has to decide something it says so: two projects declaring the same artifact name into one slice keep the first and report `SP0037`. ## Inline events and destinations An event used by exactly one production site can appear as `produces event ` inside its command. The generator counts production sites across every analyzed project, including reactors and other application code but excluding specification fixtures and `nameof` references. Runtime-typed appends and projects outside the analyzed set are not counted. Values whose event type is only known at runtime are not counted. It only inlines a local generation-one event in the same slice when the command has a required scalar identifier, the production is unconditional, and every payload member has a supported mapping. Tombstones, compensations, unsupported shapes, and payload copies of the identifier stay standalone. Both forms preserve descriptions, documentation, rename pins, and the persisted payload shape. Inlining changes where the declaration appears, not its executable meaning. Scoped embedded documents still import an inline event declared outside their scope, and the board keeps its schema and flow links. The generator retains `identifier` and explicit `for` destinations when every production uses proven command context or has an admitted explicit destination and effective route. A directly returned `(TEvent, TResponse)` tuple keeps command context when its response cannot be an event source identifier, including returns through `Task` or `ValueTask`. A returned Chronicle identity tuple can instead select a proven required input. An arbitrary `Guid` or string response is not treated as an identity; `object`, interfaces and unknown union branches cannot prove the destination. Returned `EventForEventSourceId` wrappers can supply per-production `for` and `stream` overrides, including arrays, literal lists and mixed plain-event/wrapper tuples or collections. Each occurrence keeps its own destination and route, even when the same event type appears several times. Plain siblings retain command defaults and state their own `for`. See [Event sources and streams](#event-sources-and-streams) for admission limits. Unproven destinations, explicit appends and aggregates fetched for another identity keep the conservative standalone-event fallback. Information diagnostic `SP0051` reports the omitted destination once per command rather than redirecting it. An unproven override also withholds the command route with `SP0054` in both output modes: keeping it would claim routing the generator could not establish. ## Read models Every read model the application declares gets a `readmodel` declaration of its own: each `[ReadModel]` type, and each type an `IProjectionFor` or `IReducerFor` builds. The declaration carries the properties of the type, its XML `` as `description`, and a `file` line when the file declaring it has a repository-relative path that stays true on another machine. Projections, reducers, and queries then name a read model the document declares, which is what lets them bind to the executable model. ```screenplay query ById => Book optional by id Uuid readmodel Book description "A book on the shelves and how many of it there are." file Library/Inventory/Listing/Book.cs id Uuid title String count Int projection Book => Book automap from BookAddedToInventory ``` The declaration states the shape and nothing else. No property is marked `identifier`: the executable model identifies an instance through the read model’s keyed query, so a read model with a `by` query answering with one instance is identifiable, and one without such a query is declared without claiming an identity it does not have. A query’s first required caller parameter becomes `by` only when its emitted name exactly matches an emitted read-model property name, including case. Other parameters become filters only when the query body proves a same-named scalar equality on a returned collection. A parameter with no matching property, such as `GetById(string id)` returning a model with only `Title`, is withheld with Information diagnostic `SP0019`, not guessed as a filter. A document refers to a read model by its simple name, so each read model is declared exactly once. It goes in the first slice, in namespace order, that matches the earliest of these: 1. The slice declaring a keyed query onto it. 2. The slice declaring the projection or reducer that builds it. 3. The slice its type is written in. 4. The slice declaring any other query onto it. 5. The slice declaring a command that reads it. Scoped embedded documents import a read model declared in another scope, the same way they import an event declared elsewhere. A read model is left undeclared, with Information diagnostic `SP0057`, when no declaration could say what the application holds: * two read models come out under the same declaration name (`Order_Summary` and `OrderSummary` both become `OrderSummary`), or a query or a command reads a different type under that name; * the name is already used by a concept or a type; * a value it holds, at any depth, is a collection whose elements may be null (`optional` on a collection says only that the collection may be absent), or a carried record or concept shares its declaration name with a different type the document already declares, with another type the read model holds, or with a read model; * no slice refers to it. Queries and projections referring to an omitted read model are withheld too, as are dependent screen data, tables and scenarios, with Information diagnostic `SP0030`. Authoring reads and their dependent requirements, mappings and operations are withheld with `SP0055` or `SP0053`. If these omissions empty a slice, Information diagnostic `SP0002` reports why the slice was left out. None of the concepts or types it holds are declared on its behalf. ### Properties without a Screenplay type Dictionaries, constructed generic values such as `KeyValuePair`, interfaces, abstract classes and other carried types without a Screenplay declaration are omitted with Warning `SP0030`, naming the property and its original type. This also applies to collections of those values and to properties inside carried records. Representable sibling properties remain. A carried record that loses every property cannot be declared; references to that emptied shape are omitted too. Abstract records with declarable properties remain supported. ESM v28 has no map, JSON, object or open-shape property type. The generator does not substitute `String`, invent a placeholder, or write a bare type name that nothing declares. The resulting declarations are partial descriptions, not equivalent replacements for the original C# contracts. Behavior requiring a complete affected schema is withheld with Information `SP0030`: declarative commands, projections and reactions are not restated over fewer fields, and dependent screen data, tables and actions are left out. Queries remain when their parameters are representable; a query with an unnameable parameter is withheld rather than broadened. A scenario using an affected schema or withheld behavior is omitted in full with `SP0039`; fixtures and assertions are never silently trimmed. Review these diagnostics before using the generated document as a behavioral model. ## Query descriptions and implementations A query method’s XML `` becomes its `description`. Collection query filters describe equality against the same-named read-model property, combining with `by` and other filters as AND. The generator recognizes direct `x.Property == parameter` and `parameter == x.Property` comparisons in returned LINQ `Where` and MongoDB `Find`/`Where` query chains, in the filter of Arc’s observable `Observe` queries on a MongoDB collection or an Entity Framework Core `DbSet`, and in Entity Framework Core chains awaited through `ToListAsync`, `ToArrayAsync` or `AsAsyncEnumerable`, including conjunctions with `&&`. Property and parameter names must match without regard to case; the emitted filter uses the property’s name and type, including its concept or enum. For example, this read model uses a nullable parameter to skip name filtering when no name is supplied: ```csharp using System.Collections.Generic; using System.Linq; using Cratis.Arc.Queries.ModelBound; [ReadModel] public record Book(string Id, string? Name) { public static IEnumerable ByName(string id, IEnumerable books, string? name = null) => books.Where(book => book.Id == id && (name is null || book.Name == name)); } ``` Its generated query is: ```screenplay query ByName => Book[] by id String filter name String optional ``` Only a nullable parameter whose comparison is skipped by `parameter is null || equality` becomes `optional`. Reversed operands and `== null` checks are recognized too. A nullable equality without that skip remains required: ESM v14 refuses supplied null filter values. A default alone does not prove optional filtering. Non-null defaults have no equivalent omission semantics and are withheld with `SP0019`. The generator leaves unproven parameters out with `SP0019`: different property names, `!=`, `<`/`>`, `Contains`, method calls or computed comparison operands, repeated parameter comparisons, and `||` other than the nullable null-skip form. Predicates unrelated to the returned query, conditional query bodies, and filters on single-result queries are not recovered. Paging, sorting and injected services keep their existing handling. Equality filters bind as ESM v14; filter-free documents keep their previous executable version. Set `ScreenplayOptions.MaximumExecutableModelVersion` to `SemanticVersion.V13` or earlier to withhold filters with a located `SP0019` naming the required version and cap. This applies in authoring mode too. As with other caps, the resulting document describes less behavior; withholding filters is not an equivalent implementation of the original query. Nullable scalar returns remain optional through `Task` and `ValueTask` wrappers. For collection returns, `optional` describes the collection, not its elements; nullable elements are reported as `SP0019` rather than described as an optional collection. Set `ScreenplayOptions.AuthoringOnlyConstructs` to include a `performer` with the repository-relative file containing a query’s block or expression body. A declaration without a body gets no performer. Performer-backed queries are not executable in the pinned language, so default output retains the query without its performer and reports Information diagnostic `SP0019`. A body with no portable source path is also reported rather than given a machine-specific file reference. The generator does not infer `from $context.*` from a parameter’s name or from reads inside the query body. Arc’s model-bound parameter binder supplies caller arguments, dependencies, and cancellation tokens; it does not declare a scalar parameter-to-Screenplay-context binding. Arc’s `QueryContext` is a host dependency with a different shape from Screenplay’s context. It remains excluded from caller parameters, and `SP0041` explains why no context source was inferred. Paging and sorting remain serving concerns, not context-bound scalar filters. ## Policy claim targets Readable `RequireAssertion` registrations can describe claims compared with command input properties instead of literal values. The supported shape guards `AuthorizationHandlerContext.Resource` as a `CommandContext` holding the command type, then calls the real `ClaimsPrincipal.HasClaim` with a constant claim type and a required string property. Positional record properties and nested positional record members become artifact paths; `&&`, `||`, and parentheses keep their meaning. The resource guard is removed only when every application use of that policy authorizes the guarded command type. An identifier comparison becomes `matches subject` for a command with an empty void `Handle()`; other proven comparisons keep their property paths rather than assuming the generated command retains an identifier. A keyed snapshot query can also yield `matches subject`. Its assertion must guard the `QueryContext` argument dictionary, read the key with `TryGetValue`, check that the retrieved value is a string, and compare that value with `HasClaim`. Every use must have that same sole caller-supplied string key, matching a returned read-model property. A dictionary indexer, unchecked cast, another query shape, or an additional unreadable guard is not treated as equivalent. Computed getters, transformations, nullable claim targets, conditional assertion registration, and arbitrary policy bodies retain `SP0026` rather than producing a partial recovered assertion. Existing literal-claim, role, and authenticated requirements remain supported. When nothing is recovered, the existing `require authenticated` fallback remains explicitly diagnosed; it is not a claim that the original policy allowed access. Negated assertions also retain this fallback; the generator does not emit Screenplay’s `not` policy conditions. ## Generated values and responses A `[GeneratedValue]` Handle parameter becomes a `generated` property when its concept’s `IGeneratable.New()` or `IGeneratableInContext.New(context)` proves a supported generation strategy. The property name is the attribute’s explicit name, or the C# parameter name. A directly returned event-source identity, alone or beside an event in a tuple, becomes `generated identifier` and `returns `. A direct `EventForEventSourceId(parameter, event)` target becomes `generated identifier` without inventing a response. Aggregate-backed handlers do not infer a generated destination. A generated value must be a required scalar concept backed by `Uuid`, `String`, signed `long` or `int`, with a recovered strategy, completely recovered default validation, and no compliance markers. Integral concepts support only random generation, not generated identifiers. An empty concept validator does not block generation. UUID-backed concepts retain implicit UUID v4 generation; non-default rule-free strategies require ESM v15, and admitted String-backed strategies with concept rules require ESM v20. Generated identifiers also require at least 64 random bits. The parameter must not be reassigned or passed by reference, and its emitted name must not collide with another command property. Successful scenarios can now pin these parameters; a missing or unreadable pin withholds that scenario with `SP0039`, not the command’s generation contract. Existing `Guid.NewGuid()` and proven `New()` local and response heuristics remain supported, but their successful scenarios still cause the legacy fallback with `SP0052`: values created inside `Handle()` have no pinning seam. Response-record fields must be compiler-synthesized positional properties, not explicit properties that transform their inputs. Scalar responses use `returns ` only for a direct value of the same declared type without a value-changing conversion; fully readable response records use a `returns` block whose fields refer directly to command inputs or admitted generated values. Scenarios asserting a response must state it as `then returns` (see [the scenarios a slice is specified by](#the-scenarios-a-slice-is-specified-by)). An unrecovered response assertion beside events preserves the command’s legacy form with `SP0052`. Generated values and responses select at least ESM v7; a non-default concept generation declaration selects ESM v15. Documents without those declarations retain their existing ESM version. Generated values do not exist during authorization or validation. Property rules and requirements cannot reference them (`PLAY0273`); when recovered protection reads a generated value, the command falls back to its legacy representation without `generated` or `returns`, and `SP0052` explains that the protection remains in code. The generator’s recovered authorization policies describe the caller, not generated command properties. Unadmitted generated shapes and dependent responses are omitted with `SP0052`. A required event payload mapping that needs an unavailable generated value or read causes the entire production to be withheld with Information diagnostic `SP0012`; an incomplete event payload is never emitted by pruning. Successful scenarios of that command are withheld with `SP0039`. If no event production remains, its route is withheld with `SP0054` and the command keeps its handler pointer. For an optional payload member, only the blocked mapping is omitted with Warning diagnostic `SP0012`; independent mappings and the production are retained. A production whose condition depends on an unadmitted value is omitted with an explicit diagnostic; when no production remains, the command keeps a handler reference. An unproven tuple destination reports `SP0013`. A generated identifier supplies an inline event’s destination. Standalone productions using that identity carry explicit `for `; a plain production without `for` still needs its separate allocation channel. Empty and provably response-only commands need no handler. Commands whose behavior lives in code keep a `handler` file reference by default, as before, rather than disappearing from the embedded board or being described as recording no facts. A command never carries both `produces` and `handler`. The generator preserves the legacy authoring document and adds generated values and responses only where they are admitted. Documents containing handler references still compile and round-trip, but have **no executable model** because handlers report `PLAY0268`. ## Recovering generation strategies In the shop from [Generated values](/arc/backend/csharp/commands/generated-values/), `OrderId` (`GenerateValue.TypeId("order", context)`) and `ApiKey` (`GenerateValue.Bytes(32, ValueEncoding.Base64Url)`) match the forms in the table below and recover as `generate typeid "order"` and `generate bytes 32 base64url`. `CustomerId` calls `GenerateValue.Ulid()` inside string interpolation, which reads the ambient clock. That custom body stays in code; a context-dependent factory must pass its captured context to the time-based call. Even a recovered opaque body cannot supply a generated identifier because its entropy is unknown. `InviteCode` reads its alphabet from a field, so it stays in code and is reported with `SP0073`. The `[NotAudited]` marking is not part of the model. For how Screenplay and Stage treat these strategies, see [Generated values](/screenplay/generated-values/) and [Generated values rendering](https://github.com/Cratis/Stage/blob/main/Documentation/reference/generated-values-rendering.md). Recovery reads the concept’s generation contract, including explicit interface implementations. A recognized body is one expression, or a block containing only its return: `new()`, `new Concept()`, or `` through a proven implicit conversion. Constructors and conversions must forward the primitive unchanged to the concept base. Built-in strategy arguments must be literals, encoding or checksum enum members, or a passed-through `GenerationContext` parameter. Self-contained String bodies using `context.Now` and `context.Tenant` can instead recover as inline rules, as described below. The concept owns the strategy. Its declaration gets the `generate` line; each command property says only `generated` or `generated identifier`. | Factory call | Concept generation | | --- | --- | | `Guid.NewGuid()` or `GenerateValue.Uuid()` | Implicit UUID v4; no `generate` line. | | `GenerateValue.UuidV7()` or `Guid.CreateVersion7()` | `generate uuidv7`. | | `GenerateValue.Ulid()` | `generate ulid`, uppercase Crockford base32. | | `GenerateValue.TypeId("user")` | `generate typeid "user"`; an empty prefix is supported. | | `GenerateValue.NanoId()` | `generate nanoid`, size 21 with the default alphabet. | | `GenerateValue.NanoId(64, "ab")` | `generate nanoid 64 alphabet "ab"`. | | `GenerateValue.Bytes(32)` | `generate bytes 32 base64url`, without padding. | | `GenerateValue.Bytes(16, ValueEncoding.Hex)` | `generate bytes 16 hex`, lowercase. | | `GenerateValue.Bytes(16, ValueEncoding.Base32)` | `generate bytes 16 base32`, uppercase RFC 4648, without padding. | | `RandomNumberGenerator.GetHexString(64, lowercase: true)` | `generate bytes 32 hex`; the character count must be even. | | `RandomNumberGenerator.GetHexString(32)` or the overload with `lowercase: false` | `generate bytes 16 hex case upper`. | | `GenerateValue.Pattern("[A-Z]{16}")` | `generate pattern "[A-Z]{16}"` (ESM v21). | | `GenerateValue.Pattern("[0-9]{20}", checksum: Checksum.Luhn)` | `generate pattern "[0-9]{20}" checksum luhn`; also `Mod11` and `Mod97`. | | `GenerateValue.Integer(-9, 9)` on `ConceptAs` | `generate random -9..9`, inclusive (ESM v21). | | `(int)GenerateValue.Integer(-9, 9)` on `ConceptAs` | The same random range; exactly one cast and both bounds within Int32 are required. | `GenerateValue.UuidV7(context)`, `Ulid(context)` and `TypeId("user", context)` map to the same strategies when `context` is the factory’s `GenerationContext` parameter. NanoID sizes must be 1–256 and alphabets 2–256 distinct printable ASCII characters. Byte counts must be 1–512. TypeID prefixes must be empty or 1–63 lowercase letters and underscores, starting and ending with a letter. Invalid literal arguments are withheld with `SP0073`, not emitted as invalid Screenplay. `SP0073` (`GenerationNotRecoverable`) points to `[GeneratedValue]` and names the parameter, concept and unrecovered body or arguments. The parameter stays server-owned: the generator never substitutes a caller parameter, an implicit UUID strategy, a null or a default value. Each generated parameter is read independently, so an opaque factory does not prevent recovery of another parameter’s supported strategy. Dependencies on an unrecovered parameter are tracked through local aliases, member access and returned constructions in both default and authoring output, including version-capped documents. A required payload dependency withholds the whole production (`SP0012`, Information); an optional dependency withholds only its mapping (`SP0012`, Warning). An unavailable condition withholds the guarded production (`SP0052`), rather than making it unconditional. Dependent scalar responses and whole response-record blocks are withheld with `SP0052`, operations with `SP0053`, and explicitly generated destinations with `SP0013`, without redirecting them to command context. Independent behavior remains stated. If no event production survives, its route is withheld with `SP0054` and the command retains `handler file ` instead of claiming no-fact behavior. This is a partial authoring model, not a lossless executable replacement for the original handler. The handler file reference compiles and round-trips but still receives the exact existing `PLAY0268` refusal that a handler requires a constrained implementation attachment. Missing required event mappings (`PLAY0273`) and unexpected binding failures (`SP0056`) are not accepted as limitations of opaque generation. Date-bearing patterns require the `GenerateValue.Pattern(pattern, context, checksum)` overload; the context-free overload cannot supply their time. Escaped literal braces and character-class contents do not count as date references. Pattern validity, checksum compatibility, rule consistency and identifier entropy are decided by Screenplay’s binder, not by sampling or by a second pattern evaluator in Arc. Invalid contracts are withheld with `SP0073`. Signed random ranges retain exact Int64 bounds; unsigned backings, out-of-range Int32 casts and multiple casts are refused. Compliance-marked generation remains in code. Concept rules can accompany a supported strategy when their complete default validation contract is recoverable; see [Validated generation](#validated-generation-and-esm-v20). ### Inline generation rules and the Cratis C# context A self-contained String factory becomes `generate rule New` with a `csharp` fence. Arc copies the body and removes only constructors or conversions proven to forward the primitive unchanged. For example, a factory returning `new($"{context.Tenant}:{GenerateValue.Ulid(context)}")` yields a rule body containing `return $"{context.Tenant}:{GenerateValue.Ulid(context)}";`. The accepted dependencies are literals, primitive locals, `context.Now`, `context.Tenant`, and the allowlisted types `string`, `Math`, `Convert`, `Guid`, `RandomNumberGenerator` and `GenerateValue`. Instance formatting of the captured time and tenant is supported. Compound bodies must pass their factory context to time-based `GenerateValue.Ulid`, `UuidV7` and `TypeId` calls; the context-free overloads read an ambient clock. Fields, private helpers, DI, other types, aliases, static imports, ambient clock reads, a raw `"Default"` tenant comparison and non-self-contained bodies remain in code with `SP0073`. No generation-rule `file` fallback is invented. Opaque rule entropy is unknown, so rules never supply generated identifiers. The version-1 ValueGeneration context is a language-neutral descriptor. **The Cratis C# target binds it to Fundamentals’ `Cratis.Concepts.GenerationContext`**, not to `Cratis.Screenplay.Contexts.GenerationContext`. Passing `context` to `GenerateValue.*` is therefore valid in these recovered bodies and makes them target-bound. A `csharp` fence alone does not select that binding; another provider must admit the body against its own native context. Recovery preserves the body, but does not execute it or promise that another target can compile it. See [Screenplay’s context binding](/screenplay/context/). Pin a concrete UUID, text or integral value with [Pin generated values in command specs](/arc/backend/csharp/testing/generated-values/). A pin for the issued command’s generated identifier becomes indented `for ""` beneath `when`; other pins become `generated = ""`. UUIDs are normalized to lowercase `D` format; text is preserved exactly and must match the strategy’s encoding, length, case and unused trailing bits. Direct concept constructions and one-hop stable held fields are readable. Given and then occurrences on the pinned identifier stay implicit. Generic pins are accepted only when their command type is the issued command. Nested-command pins, `New()` pins, `AllowGeneration()`, conditional or repeated pins, mismatched shapes and missing successful-scenario fixtures withhold only the scenario with a located `SP0039` explaining the fix. Models below v15 retain legacy fixture reuse. In an ESM v15 model, Screenplay rejects an append to a generated identifier whose whole event source already has facts. ## Validated generation and ESM v20 A readable String-backed factory and its `ConceptValidator` can now be recovered together. The generator retains the concept’s rules, including when scenarios exercise it, and uses Screenplay’s rule-consistency checks rather than sampling generated values. For example, `GenerateValue.NanoId()` with a matching validator produces: ```screenplay concept InviteCode : String generate nanoid validate matches "^[A-Za-z0-9_-]{21}$" ``` Proven rules admit generation silently. Non-opaque Unproven rules remain stated: Information diagnostic `SP0078` carries `PLAY0689` and its reason, not a claim of proof. A Refuted rule withholds generation and dependent claims; `SP0073` carries `PLAY0688` and the witness. The validation contract remains intact. Conditional registrations, unreadable inherited validation and opaque predicates also withhold generation rather than treating partially recovered rules as the whole validator. Deliberately excluded non-default rule sets are not part of default validation. UUID concepts have no admissible non-opaque rule in Screenplay 4.164’s declarative algebra. A UUID validator such as Guid `NotEmpty` therefore does not produce executable generation. Its original rules remain in the analyzed model and its validator is retained through an opaque source-file attachment, not restated as an invalid text rule. Dependent productions and pins follow the same fail-closed pruning as an unrecoverable factory. An empty UUID validator still permits the previous ESM v7 generation contract. A valid literal pin must satisfy both the generated shape and the concept’s rules. An incompatible successful scenario is omitted with located `SP0039`; its rule is never deleted or its pin rewritten to make the scenario pass. Screenplay classifies invalid generated fixtures as `Rejected(Contract)`, a server defect rather than client validation. Reference execution cannot evaluate opaque validation; those scenarios are withheld while the validator is preserved. ### Keep validated generation below ESM v20 Set `ScreenplayOptions.MaximumExecutableModelVersion` to `SemanticVersion.V19` or earlier when a consumer does not admit V20. The generator keeps the validation contract but withholds the strategy, generated values and dependent mappings, responses, destinations and scenarios, with diagnostics naming ESM v20 and the cap. This applies in both generation modes. Raising the cap to `SemanticVersion.V20`, or leaving it unset, permits admitted validated strategies; it does not make opaque or refuted rules executable. Rule-free output retains its prior version and bytes. A serialized generation strategy retains Screenplay’s Guarded freshness semantics; validator presence alone does not add a freshness guarantee. ## Pattern, random, rule and checksum recovery in ESM v21 An unconditional String concept rule `RuleFor(value => value.Value).Must(v => GenerateValue.HasValidChecksum(v, Checksum.Luhn))` becomes `validate checksum luhn`. `Mod11` and `Mod97` map likewise; `None`, conditional predicates and arbitrary helper predicates are not declared as checksum rules. Screenplay checks the complete generation-and-validation contract, including pattern checksums, without redrawing invalid generated values. Pattern, random and inline rule generation, and checksum validation, select ESM v21. Set `ScreenplayOptions.MaximumExecutableModelVersion` to `SemanticVersion.V20` or earlier for a consumer that has not admitted v21. In both default and authoring output, Arc withholds generation and its dependent values, mappings, responses, destinations and scenarios. It preserves other declarative rules and retains the original checksum validator as a source attachment, reporting `SP0073` with the required version and cap; it never silently drops a checksum to downgrade generation. A V21 cap or no cap admits supported contracts. Existing v15/v20 contracts retain their previous version and output. Concrete pattern and random fixtures are checked by Screenplay’s reference runner, including checksum-conditioned support and signed integral bounds. An invalid fixture withholds its scenario with `SP0039`, not its valid generation strategy. A rule body’s fixture supplies its result without executing the opaque C# body. ## Keeping documents below ESM v15 If your renderer does not admit ESM v15, set `ScreenplayOptions.MaximumExecutableModelVersion` to `Cratis.Screenplay.Semantics.SemanticVersion.V14` or earlier. Non-default `generate` lines are withheld with located Information diagnostic `SP0073`, naming the required version and cap. Their generated command values and dependent responses or mappings remain in code with `SP0052`; pins against withheld values omit the scenario with `SP0039`. This applies in authoring mode too. Plain UUID v4 generation writes no strategy line and keeps its previous document bytes. Embedded generation uses the same cap on `EmbeddedDocumentOptions`; MSBuild projects set `CratisEmbeddedScreenplayMaximumExecutableModelVersion` to `14.0`. A cap describes less behavior, not an equivalent implementation of the original strategy. ## Enumerations An enum member’s number is its stored identity. Reordering bare members in a model can change those numbers when code is rendered again. The generator therefore pins every recovered C# number, including implicit continuation and positional `0..n-1` values. This applies to direct enums and enum-backed `ConceptAs` concepts, in both default and authoring output. For example, this C# declaration: ```csharp public enum Status { Draft = 1, Issued = 4 } ``` becomes this document-level declaration: ```screenplay concept Status : Enum draft = 1 issued = 4 ``` Names still identify members in mappings, validation rules and scenarios: `status = "issued"`, not `status = 4`. Sparse, negative and reordered numbers select ESM v12. Explicit positional pins keep the previous executable version and canonical bytes, even below v12. Never renumber a published member or reuse its number for another member: persisted data still carries the old identity. Screenplay admits only unique int32 numbers. For aliases, the generator keeps the ordinal-first C# member name for each number, matching scenario constant resolution, and drops the other names with warning `SP0069`. A number outside int32 cannot be pinned; the whole enum falls back to bare, positional members with warning `SP0068`. Members whose names collide after conversion keep only the first name with `SP0068`, without renumbering surviving members when their numbers can be emitted. A `[Flags]` enum still declares one member at a time. Named combinations such as `All = 7` are ordinary members, not set semantics. Warning `SP0067` names the enum and its analyzed property uses when combinations exist that no member names. It is absent when every combination of positive members is named. The warning looks at properties of the types the document declares; an enum used only as a query or method parameter, and negative members such as `All = -1`, are not considered. Unnamed combinations in scenarios remain unsupported by [Screenplay’s flags work](https://github.com/Cratis/Screenplay/issues/319). ## ESM v22 atomic reaction batches When an emitted reaction trigger or refusal branch has two or more `produces` entries, the generator declares `reaction batches atomic` at the application root. Screenplay 4.181.0 admits this as ESM v22, with `reactionProductionMode: "atomic"` in the executable model. Each trigger’s direct productions append all-or-none, including batches with uniqueness constraints and explicit `for` destinations. A single-production reaction does not add the declaration. Invoked commands and selected refusal branches are separate batches. A later failure does not roll back previously accepted batches or commands; the declaration does not promise atomic observer updates or recovery from infrastructure failures. For a consumer that has not admitted v22, set `ScreenplayOptions.MaximumExecutableModelVersion` to `Cratis.Screenplay.Semantics.SemanticVersion.V21`. Embedded generation uses the same cap on `EmbeddedDocumentOptions`; MSBuild projects set `CratisEmbeddedScreenplayMaximumExecutableModelVersion` to `21.0`. This keeps legacy output: no atomic declaration, and constrained batches that cannot match per-fact appends remain file-backed with `SP0070`. The cap applies in authoring mode too. An early stream closure, unreadable constraint coverage, or a rejecting constraint not represented in the document still keeps a batch in code with `SP0070`, even at v22. This includes class-level `[Unique]`, which the generator does not emit, and fluent uniqueness rules it cannot recover faithfully. Closing reaction batches await [Screenplay’s closure admission](https://github.com/Cratis/Screenplay/issues/770); atomic mode does not admit direct stream routes either. ## Keeping documents below ESM v12 If your renderer does not admit ESM v12, set `ScreenplayOptions.MaximumExecutableModelVersion` to `Cratis.Screenplay.Semantics.SemanticVersion.V11`. Embedded generation exposes the same cap on `EmbeddedDocumentOptions`; MSBuild projects set `CratisEmbeddedScreenplayMaximumExecutableModelVersion` to `11.0`. A cap below v12 keeps bare names for non-positional enums and reports `SP0068` (Warning), listing their numbers and explaining that positional output does not preserve stored identity. Positional enums still get explicit pins because those pins do not raise the executable version. The cap applies in authoring mode too; it does not make other authoring-only constructs executable. ## Keeping documents below ESM v7 If your renderer does not yet admit ESM v7, set `ScreenplayOptions.MaximumExecutableModelVersion` to `Cratis.Screenplay.Semantics.SemanticVersion.V6`. The default is `null` (no cap), admitting the latest supported constructs. A cap below v7 withholds generated identifiers, other generated values, and scalar or record `returns` blocks, including `[GeneratedValue]` recovery. Scenarios with generation pins are omitted with `SP0039` rather than emitting `for` or `generated` fixtures against a legacy command. Commands keep the same legacy productions or handler references used when generation cannot be represented, and Information diagnostic `SP0052` names the cap. Embedded generation exposes the same nullable version on `EmbeddedDocumentOptions`. For MSBuild generation, set `CratisEmbeddedScreenplayMaximumExecutableModelVersion` to the canonical version `6.0`; leave it empty for no cap. For example, this lets you generate a document for `cratis render` versions that refuse ESM v7 with `CLI-RENDER-004` until their Stage renderer admits it. The separately shipped CLI must expose the generation option before it can be selected there. The cap also withholds v7 command values and responses when `AuthoringOnlyConstructs` is enabled, without disabling its other authoring constructs. It does not turn handler references or authoring-only constructs into executable behavior, nor downgrade earlier ESM features: a document may still have an existing binding limitation. ## Event sources and streams A route tells you where a command’s facts land, not just which event type it produces. Default output includes readable `eventsource` and source-owned `stream` declarations, command routes, and specification routes. These constructs select ESM v8. A command routed this way no longer emits a legacy `concurrency` block. When it also opts into concurrency, Information diagnostic `SP0044` names the omitted concurrency dimensions: the route does not enforce them, and ESM v8 has no executable concurrency syntax. Concurrency-only commands keep that block, with `SP0044` describing its non-executable legacy meaning. Command stream ids can come from a direct required scalar command property or a portable literal attribute. A template containing exactly one property placeholder and no surrounding text, such as `[EventStreamId("{Name}")]`, maps that direct required portable property. Explicitly typed composite parts in an application model are emitted together, in declaration order. Other C# formatting templates are not assumed to have Screenplay’s escaped composite encoding: an unproven template remains in code with `SP0054`, rather than becoming a different stream id. ### Per-production destinations and routes A returned `EventForEventSourceId` can override the command’s destination and classification independently. For example, this command records an audit fact on its own source and a registration on a different source: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.Keys; [EventType] public record AuthorAudited(string Name); [EventType] public record AuthorRegistered(string Name); [Command, EventSourceType("Account"), EventStreamType("Main")] public record RegisterAuthor([Key] string Id, string OtherId, string Month, string Name) { public object[] Handle() => [ new AuthorAudited(Name), new EventForEventSourceId(OtherId, new AuthorRegistered(Name)) { EventSourceType = "Ledger", EventStreamType = "Postings", EventStreamId = Month } ]; } ``` Its generated command body includes these productions; the document also declares `Account.Main` and the scalar-keyed `Ledger.Postings` stream: ```screenplay produces event AuthorAudited for id name String = name produces event AuthorRegistered for otherId stream Ledger.Postings streamId = month name String = name ``` The destination must be a proven required scalar command property or admitted generated identifier. Types remain nominal: a UUID-backed identity concept is not silently converted to bare `Uuid` to satisfy another source’s identifier. Key-only fan-out without an admitted stream or generated command identifier stays withheld because the pinned binder cannot admit it. A complete override can be recovered even when the command declares no default route. Constructor argument names are bound by parameter, not written order. Legacy source, stream and key sentinels inherit the corresponding command values. Definition routing is coupled instead: `EventSource = typeof(TSource)` selects that definition and its own `EventStream`; a stream-only definition override uses the command’s definition. Legacy source/stream type strings clear definition routing. Definition and constant-backed stored names retain their model names and `id` pins; unknown ownership or incompatible declarations are refused. The emitted override states the complete effective route, including an inherited stream id, rather than assuming that changing streams clears its key. Stream keys admit direct required portable inputs and nonempty NFC literals. Proven scalar conversions preserve nominal types, including UUID `ToString()`/`ToString("D")` and integer-backed concepts formatted with `CultureInfo.InvariantCulture`; other formatters are not assumed equivalent. A single-placeholder **attribute** template can map an input, but wrapper text such as `EventStreamId = "{Month}"` is literal text, not an inferred template. Computed ids, multipart formatting, nested or optional inputs, generated stream keys, unreturned constructions, collection spreads and unsupported metadata such as subject, occurrence time, tags or causation stay in code. An unavailable generated dependency in a destination or route withholds the whole production, never retargets it; dependent successful scenarios are withheld too. Property-backed stream-key recovery assumes nonempty, non-sentinel inputs. Arc can replace a default/unset wrapper key with the command’s key at runtime; the generated scalar mapping always reads the property and does not express that value-dependent fallback. If an input can take the sentinel path, keep that routing case in source rather than treating the recovered mapping as an equivalent contract. Overrides require ESM v10. A `MaximumExecutableModelVersion` cap of v7, v8 or v9 withholds overrides in **both** modes, with located `SP0054` naming v10 and the cap. `SP0051` preserves the unknown-destination fallback; command defaults are not substituted for an omitted override. Unchanged default-only routes still need only v8. A generated-identity wrapper that retains command context inherits that route without a production override; it keeps v7 without routing, or v8 with a default route. When an explicit destination needs a production stream identical to the default stream, Screenplay can report the redundant-route advisory `PLAY0664`; removing that stream would make non-identifier fan-out unbindable. Recovery does not establish renderer support. In particular, Stage must refuse unkeyed overrides under keyed defaults until its Arc adapter prevents sentinel key inheritance. The generator describes Arc’s actual effective key; it does not promise render-and-recover equivalence. Property-path mappings and routes on handler commands remain authoring-only (`PLAY0268`). Generated-property mappings, incompatible source or stream declarations, and the reserved source name `Default` cannot be emitted faithfully (`PLAY0273`); `SP0054` identifies the reason and the complete route is withheld. No partial stream-id mapping or orphan source declaration is emitted. Set `ScreenplayOptions.MaximumExecutableModelVersion` to `SemanticVersion.V7` or earlier to retain the previous default routing behavior: command routes and their declarations are withheld with `SP0044`, and routed scenarios are withheld whole with `SP0060`. Authoring mode still permits syntax-only routes regardless of this cap. ## Protected command reads and ESM v25 A command that decides from a read model should decide from a state that is still true when it commits. Arc gives you that with [read protection](/arc/backend/csharp/chronicle/consistency/reads/): with `ReadProtection(ReadProtectionMode.On)` a read-model parameter of `Handle()` or `Provide()` is a protected decision read, and Chronicle rejects the command when a relevant event arrives after the read. The generator states those reads in the document, so the decision and its guarantee travel with the model. A document states them under one root line, `command reads protected`, which selects ESM v25 (Screenplay decision 0085). Under that line every `reads` claims protection, and the Screenplay compiler refuses a read it cannot protect instead of treating it as advisory. The generator emits the line only when at least one command keeps a protected read, in the default output and with `AuthoringOnlyConstructs`. ### What becomes a protected read A read is a candidate when all of these hold: * The effective read protection of the command is `On`: declared on the command, inherited, set on the assembly, or set by the `CratisReadProtection` MSBuild property. `[Unprotected]` on the command turns it off. * Neither the parameter nor the read-model type carries `[Unprotected]`. `Off`, `Measure` and `[Unprotected]` reads are advisory and are never promoted. `Measure` only observes; it does not protect. * The key is known. `[LookUpBy(nameof(Property))]` names the command property in every mode. Under `On` without it, the generator uses the one command property of the read model’s key type, falls back to the command’s event-source key, and treats several matching properties as ambiguous, which leaves the read in code. Name one with `LookUpBy`. * The read model is not stream-scoped. A read model with `[EventStream]` is read per stream, the runtime rejects a look-up by key for it, and Screenplay cannot state it until [Cratis/Screenplay#805](https://github.com/Cratis/Screenplay/issues/805). It stays in code with `SP0079`. * Absence is rejected. A non-nullable parameter counts, since Arc rejects the command when the instance is missing. A nullable parameter counts only when `Handle()` opens with a guard such as `if (room is null) return ValidationResult.Error("...");`. A nullable parameter without that guard means absence is a valid state, and it is not stated as a protected read. | In C# | In the `.play` document | | --- | --- | | `ReadProtection(On)` and a `RoomOccupancy` parameter keyed by `RoomId` | `command reads protected`, then `reads RoomOccupancy as occupancy by roomId` | | a leading `if (room is null) return ValidationResult.Error(...)` | nothing: the model rejects an absent read itself, and the generator reports that its message is not carried as `SP0079` | | `if (room.FreeBeds < Guests) return ValidationResult.Error("...")` as a leading guard, or in `Provide()` | `require occupancy.freeBeds >= guests` with the message | | `new RoomBooked(Guests, room.FreeBeds)` | `freeBeds = occupancy.freeBeds` in `produces` | Screenplay names a read by its alias, or by its view when it states none. The generator writes `as `, the parameter name, and paths through the alias. When the parameter is named as the view (`Current? current`), it drops the alias: the read is `reads Current by sourceId` and its paths are `Current.value`. The guards Stage renders around the same command, such as `if (current is null) return OneOf<...>.FromT1(ValidationResult.Error(...))` and `if (!(!Object.Equals(current.Value, ""))) return ...`, are read the same way: `OneOf` and `Result` wrappers are looked through, an absence guard may use any message because the model’s own message applies, and `!`, `==`, `!=` and `Object.Equals` over a read become the requirement. A requirement guard keeps its message only when it is a constant. Otherwise the guard stays in code. ### When Screenplay refuses a read Whether Screenplay admits a read is Screenplay’s decision, so the generator asks the compiler instead of restating its rules. It emits the candidate reads with the preamble, binds the document, and maps every `PLAY0780` to `PLAY0785` diagnostic to the read it concerns. That read, and the requirements and mappings that depend on it, stay in code, and each is reported as Information `SP0079` with the PLAY code and its message. A refusal that cannot be attributed to a read, or a bad read path (`PLAY0784`, `PLAY0785`) with no other refusal behind it, is the generator being wrong rather than your source, and is reported as Error `SP0056`. The document is emitted again, and without the preamble when no read is left. A refusal of one read never affects another. For `PLAY0781` to `PLAY0785`, the compiler identifies the command and read through named, typed diagnostic arguments rather than message wording or source lines. Two refused reads in one command each receive their own report. Typical reasons are a view that never becomes absent (no `RemovedWith`), a view with joins, children or a payload key, a view without a keyed query, a command that also declares concurrency or routes, and a key that is not a string, concept over a string, or GUID. A candidate whose key the compiler cannot accept (for example an optional property) is refused the same way. A read that is refused is never emitted as an unguarded `reads`. ### What stays authoring-only `provide`, `derive` and file forms, optional and advisory reads, reducer reads, stream-scoped reads (`[EventStream]`, `SP0079`), reaction reads, and validators over read-backed rules are not admitted by ESM v25. They stay in code. With the preamble present, advisory reads are left out of authoring documents as well, with `SP0055`, because every `reads` under the preamble claims protection. ### Keeping documents below ESM v25 With `MaximumExecutableModelVersion` below v25, protected reads are not stated as protected reads and the document is what it was before: executable output leaves the reads in code and authoring output keeps its legacy `reads`. Warning `SP0079` names ESM v25. Every other `SP0079` is Information. ### What the document states The document states the declared protection. The runtime option `ReadProtection:MaximumMode` can lower `On` at run time, for example to `Measure` while you roll protection out, and the document does not follow it. A read the document calls protected is protected only while the effective mode at run time is still `On`. ### Testing it The generator’s own round trip proves the result: the document compiles, binds at v25 and prints back unchanged. Check a command of yours the same way: generate with the options you use, assert that the source contains `command reads protected` and `reads by ` (with `as ` when the parameter is not named as the view), and that no `SP0079` names a read you expect to be protected. ## Including authoring-only constructs Set `ScreenplayOptions.AuthoringOnlyConstructs` to `true` when you want a fuller authoring document rather than an executable model. It defaults to `false`. Embedded generation exposes the same boolean on `EmbeddedDocumentOptions`; MSBuild projects can set `CratisEmbeddedScreenplayAuthoringOnlyConstructs` to `true`. The separately shipped CLI must expose the option before it can be selected there. The option retains handler references even for response-only commands, and adds returned command operations with their external system and execute/compensate implementation files, plus syntax-only route mappings outside ESM v8’s executable subset. It also retains generated authoring shapes outside the executable subset. An operation must use exactly one external system in the current grammar. Authoring mode retains existing, value-bearing concurrency dimensions; observer filters and dynamic concurrency flags without a value are still reported rather than invented. It also describes keyed read-model dependencies of `Provide()` and `Handle()`, simple provisioning rejection comparisons as acceptance requirements, and event mappings from read-model members. Reads use the key Arc resolves for them, honoring `LookUpBy` and otherwise the command’s proven event-source key. Advisory reads (`Off`, `Measure` or `[Unprotected]`) are described this way; [protected reads](#protected-command-reads-and-esm-v25) are executable. Arbitrary provisioning stays in code and is reported; no `provide` block is generated. These documents still compile and round-trip, but **there is no executable model while `PLAY0268` constructs are present**. Binding legacy (advisory) reads also reports `PLAY0271` because they cannot imply decision consistency. Enabling the option does not execute operations, generate implementation code, or weaken those admission checks. Unsupported types, ambiguous names and unreadable behavior retain diagnostics instead of guessed output. ## The generator checks its own output Most diagnostics name something about *your application* — a construct the language cannot hold, source that did not compile, projects that share no directory. `SP0034` and `SP0056` name defects in the generated document instead. After the document is written, the generator hands it straight back to the Screenplay compiler. If the compiler rejects it, `SP0034` is reported as an error — because a `.play` that does not compile is output nobody can use, and there is no way of writing an application that avoids it. This is not a mode you turn on: it runs on every generation, since the only way a rejected document is ever found is by reading each one back. ```text Error SP0034: The generated document did not compile - 1 error(s), the first being 'Invalid description 'description RequestDescription' - expected 'description ""'' on line 6. That is the generator being wrong rather than anything the source declared, and the document is returned as it stands so the line can be read (Library) ``` A document that compiles is also passed to Screenplay’s executable semantic binder. Each unexpected binding error becomes `SP0056`, carrying the binder’s code, message, line and column. It identifies a generator defect, not an application defect. `PLAY0268` alone does **not** mean an expected limitation: Screenplay also uses it for malformed bindings, including incompatible condition operands. The generator accepts only the pinned binder’s known admission messages: * In both modes: legacy handler attachments; read models without an unambiguous keyed query or conventional `*Id` property; queries with unsupported scope, implementation, caller-argument, or return shapes; concept compliance attributes whose execution needs portable data-subject semantics. Admitted collection and observable queries receive no exemption for unresolved references. * With authoring-only constructs enabled: the explicit refusal of operations and systems, or property-path stream-id mappings. * Legacy concurrency messages (`PLAY0271`) remain accepted for unrouted commands. Routed commands carrying legacy concurrency fail default verification. Legacy read messages remain accepted only in authoring mode. Informational diagnostics do not produce `SP0056`. Before accepting a known limitation, the generator checks the loaded Screenplay compiler’s contract catalog: the code must be neither retired nor reserved, and its supported severities must include Error. A compiler upgrade that removes that status stops the exemption. Message matching stays exact because the contract lists codes, titles and severities, not the individual reasons that distinguish an admission limit from malformed output. Queries with the same name in different slices keep their names and bind by slice. An ambiguous query reference receives no exemption. The classifier matches the complete reason after a declaration name, or the complete authoring-feature refusal message. Unknown or changed messages fail closed. Operand/type mismatches, undeclared operands, ambiguous references across slices, and invalid parent keys produce `SP0056` even when Screenplay reports them as `PLAY0268`. Standalone generation reports `SP0056` as **Warning**. Embedded generation compiles every scoped document independently, but binds only the final assembly/root document that holds the whole application. Scoped documents contain subsets of that document and symbolic imports the executable binder does not admit; they are compiled but semantically verified through the whole-application document, not bound in isolation. Embedded generation reports `SP0056` as **Information**, so MSBuild warnings-as-errors cannot break a consumer build because of a generator limitation. Arc’s end-to-end CI gate takes the stricter responsibility: it fails on **any `SP0056`, regardless of severity**, or any unexpected binding error, and runs all three sample applications with authoring-only constructs both disabled and enabled. Accepting an admission limitation never exempts unresolved references, type mismatches, or other binding defects. The document is still written out, so you can open it at the reported line and see what happened. If you hit this, it is a bug worth [reporting](https://github.com/Cratis/Arc/issues) — include the line, and the C# declaration it came from. Source that did not compile (`SP0024`) suppresses `SP0034` and `SP0056` **when it is reported as an error** — a model recovered from symbols the compiler never accepted describes an application that does not exist, so a poor document made from it is a consequence of the broken build rather than a second, separate defect. Fix the build and generate again. As a warning it suppresses nothing. That severity says the model stands, and a document built from a model that stands is exactly what the check exists for — suppressing it there would hand back a `.play` the language rejects with nothing wrong reported. ## A value is only ever what the source states Wherever the document states a value — the mapping a command’s `produces` writes into an event, the values a scenario is issued with, the message a validation rule fails with — it states what the source states and nothing beyond it. Two sources survive: a constant the compiler already holds, and a path into the command’s own input. Anything arrived at while the request runs is left out and reported, because a guessed value is worse than an absent one. Messages are where that bites. An application speaking more than one language declares each message once in a resource and names it from the validator, and the property it names resolves its text against the caller’s culture — so there is no text for the compiler to hold, and no single text the document could honestly state. The key is there to be read even though the text is not, and it is the better of the two to take: text would settle the document on one language the application itself never settled on. So the key is what is written, unresolved: ```text command RequestBook title String validate title not empty message $strings.RequestMessages.TitleRequired ``` A key is qualified by the class declaring it, because a key is unique to its own resource and to nothing wider. Two areas of one application both requiring an organization number is ordinary rather than a mistake, and bare, those two would be one key that can carry only one text. A message genuinely put together in code — `string.Format`, an interpolation — still has no text to write down, and is reported as `SP0016` rather than guessed at. So is a key the language has no way of writing: a reference is a path of bare words, and a resource key is under no such constraint. ## Operational secrets A concept carrying both `[Encrypted]` and `[NotAudited]` becomes `secret`: encrypted at rest and withheld from the command’s causation chain. With `[PII]` as well, it becomes `pii secret`; Screenplay’s C# providers render only `[PII]` for that combination, and Chronicle rejects `[PII]` combined with `[Encrypted]`. Encryption or audit suppression alone is not the secret contract; `SP0058` (Information) reports partial or member-specific markings without adding `secret`. An existing `[PII]` marking is retained. Subject encryption scope, whether omitted or explicit, stays implicit as bare `secret`. For Namespace or Global scope, default output retains `secret` and reports `SP0061` (Information): the requested scope is not stated, so the document implies Subject scope. Set `ScreenplayOptions.AuthoringOnlyConstructs` to emit `secret scope namespace` or `secret scope global` in the concept body. These details are syntax-only and do not raise the ESM version. No scope is emitted for `pii secret`, because providers ignore it. Changing encryption scope for persisted events requires a new event generation. Member `[PII]` markings are read from properties and positional constructor parameters. Screenplay classifies only concepts, so `[PII] string Email` cannot be stated; `SP0062` (Information) names the member and suggests a personal-data concept. The generator does not invent one. A member marking on an existing concept promotes the whole concept to `pii`. If the concept itself lacks `[PII]` and other analyzed uses are unmarked, `SP0063` (Information) lists those uses, which the document now also classifies as personal data. When every analyzed use is marked, no promotion diagnostic is reported. Screenplay rejects `pii` and `secret` concepts as command identifiers, event-source identifiers, event-production `for` destinations, stream ids and stream-id parts, including the command properties supplying route mappings (`PLAY0515`). The generator checks the positions actually emitted in the document, not every identity found during analysis. If an identifier and its route are withheld, that analysis alone does not remove a true compliance claim. For a concept occupying a rejected position, the generator leaves its `pii`/`secret` annotations off globally, because Screenplay classifies the concept rather than individual uses. `SP0059` (Information) names the concept, the withheld annotations, and each retained identity use; those identities and routes remain stated. ## Identifiers, event documentation, and named rules A command’s required scalar event-source key is emitted with `identifier`, and its productions carry `for `. The generator follows Chronicle’s key rules: a property assignable to `EventSourceId`, a generic `EventSourceId`, or `[Key]` on the property or its matching constructor parameter. An implicit conversion from a `ConceptAs` to `EventSourceId` does not make that concept a key. An `ICanProvideEventSourceId` implementation is recovered only when it directly returns a required scalar property. Several candidates produce `SP0049`; an unreadable or optional identity produces `SP0050`. In either case the command remains, but no identifier or destination is guessed. An event’s XML `` becomes its `description`; `` becomes a Markdown documentation block. References such as `` retain their names in backticks. Remarks containing a Markdown fence are left out with `SP0014`, because the fence cannot be nested. A generation-1 `[EventType("PreviousName")]` whose persisted name differs from the current type name emits `id "PreviousName"`. Later generations still report `SP0014` and do not emit a rename pin. ### Validation rules The generator emits unconditional declarative rules such as `NotEmpty`, `Length`, and `GreaterThan` for commands and concepts. Arc validates with the default FluentValidation context, which runs rules with no rule set and rules whose sets include `default`. FluentValidation splits a rule-set name on commas and semicolons and trims each part. The generator follows that behavior for compile-time constants, comparing `default` without regard to case: `RuleSet("default,draft", ...)` and `RuleSet("draft; Default", ...)` are retained too. Rules inside any set without `default`, or an unproven rule set, are omitted with `SP0016`, naming the rule and rule set. Rules held to `When` or `Unless` (including async conditions and enclosing condition blocks), or declared after a possible constructor early exit such as `if (...) return;` or `throw`, are also omitted with `SP0016`. The diagnostic names the condition or early exit. A chained condition applies to preceding validators, or just the immediately preceding validator when `ApplyConditionTo.CurrentValidator` is specified; unconditional siblings remain. `DependentRules` conditions only its callback’s rules on the parent succeeding, not the parent rule. `Otherwise` conditions only the alternative callback’s rules on the enclosing `When` or `Unless` branch not applying. Those callback rules are omitted while unconditional rules outside them remain. A throw caught before registration does not count as an exit when a covering catch has no explicit exit, its filter is absent or proven true, and the catch and any `finally` block have reachable endpoints and no explicit exits. A catch-all, `catch (Exception)`, or a catch covering the resolved exception type can establish this. Rules inside the throwing `try` still remain conditional: catching the throw would skip their registration. Unknown filters, unproven exception types, and possible exits in the catch or finally are treated conservatively and can keep rules out of the document. Screenplay has no `when` clause on validation rules, and the generator does not invent an implication for an arbitrary C# predicate or constructor condition. Match rules must belong to Screenplay’s portable pattern dialect. Refused constructs such as regex lookaround are withheld with `SP0016`, naming the property or concept and the language’s reason. A generated concept with such a rule also loses its generation contract and dependent claims rather than silently becoming a less constrained generator. An unconditional top-level property rule written as `.Must(IsKnownName)` can become a named `rule IsKnownName` with a repository-relative `file` referencing the predicate’s source. Its method name is preserved. Conditional named predicates remain omitted with `SP0016` rather than stated as unconditional constraints. Named rules are opaque to reference execution. The generator withholds them when any scenario exercises the affected command, preserving the scenario’s legacy declarative behavior instead of changing it to `SemanticUnsupported`. For a concept rule, this includes every command carrying that concept, directly or through nested composite properties and collections. `SP0016` names the command or concept and the scenario commands that caused the omission. This safeguard applies to both success and rejection scenarios, in both default and authoring-only mode. Generated concepts are the exception: their rules are never removed to preserve a scenario. Opaque generated-concept validation instead withholds generation and any scenario that reference execution cannot evaluate. Predicates without a portable implementation file, inline lambdas, and unsupported rule shapes also remain omitted with `SP0016`; a message following an omitted validator does not attach to the rule before it. Metadata modifiers such as `WithErrorCode`, `WithSeverity`, and `WithName` do not add a validator, so a subsequent `WithMessage` still belongs to the preceding retained rule. ### Date comparisons and ESM v26 The generator reads chronological `==`, `!=`, `<`, `<=`, `>` and `>=` comparisons on `DateOnly` (`Date`) and `DateTime`/`DateTimeOffset` (`DateTime`) command properties, including concepts wrapping those types. Concept properties retain their concept names; comparisons use their backing primitives, which must match on both sides (`Date` with `Date`, `DateTime` with `DateTime`). A `produces when` guard can compare two required command properties or a required property and a literal. Readable authoring-only `Provide()` guards can use the same comparisons in `require`. Command property validators support `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan` and `LessThanOrEqualTo` against **literals only**. Nullable validation subjects are supported: an absent value passes these rules unless a separate `NotEmpty` rule rejects it. Optional operands in command conditions remain unsupported. Literal recovery accepts `DateOnly.Parse`, `DateTime.Parse` and `DateTimeOffset.Parse` with constant text and `CultureInfo.InvariantCulture`, and `new DateOnly(year, month, day)` with constant arguments. A concept literal can wrap one of these forms with `new ReservationDate(...)`, where the constructor forwards the primitive unchanged, or receive the primitive through an implicit conversion. Dates print as `yyyy-MM-dd`. Instants must include an explicit offset or `Z`; they print in UTC with seven fractional digits, for example `2026-01-01T00:00:00.1234567Z`. Comparisons retain tick precision rather than truncating to milliseconds. Compared `DateTime` command inputs must also be bound with an explicit offset or `Z`, before deserialization can supply a local offset. The generator reports unsupported comparisons with the property and reason instead of emitting a condition or rule the compiler cannot bind: * Mixed `Date` and `DateTime` operands, invalid dates, and instant literals without an offset. * Clock expressions such as `DateTime.Now`, `DateTimeOffset.UtcNow` and `DateOnly.FromDateTime(DateTime.Today)`. * Date rules in `ConceptValidator`, which would also affect query inputs and client validation. A command validator may compare a concept-typed property against a literal; this does not admit concept-level rules. * Dotted concept paths such as `End.Value`; compare the concept property itself. * Property-to-property date validation rules, protected read paths, context operands, and date ordering in reaction `where`. These boundaries follow [Screenplay decision 0087](https://github.com/Cratis/Screenplay/blob/main/decisions/0087-admit-chronological-date-and-datetime-ordering-in-command-conditions.md). Client validators never carry date ordering; it is a server-side contract. Set `ScreenplayOptions.MaximumExecutableModelVersion` below `SemanticVersion.V26` to withhold date comparisons in both default and authoring output. A located diagnostic names ESM v26 and the cap. As with other caps, the document then describes less behavior, not an equivalent implementation of the original command. Without date comparisons, a document keeps its existing executable version. ### Uuid equality and ESM v28 The generator reads `==` and `!=` between Uuid operands: a `Guid`, or a concept or `EventSourceId` identity backed by one. A `produces when` guard can compare a required command property with another command property or a literal. Readable authoring-only `Provide()` guards can also compare a protected read path, such as `grant.Instance!.MemberId == RequestedBy`, in `require`. A literal on either side is moved to the right. Literals are printed in the canonical form, lowercase and hyphenated. The generator reads `Guid.Parse("...")`, `new Guid("...")`, `Guid.Empty` and a concept constructed from one of those, and rewrites any other spelling of the same value (uppercase, braces) to the canonical one. Both sides must be the same concept; a bare `Guid` can be compared with any Uuid concept. These are refused and left in code, with a located diagnostic naming the property and the reason: * Different concepts or identities on each side, such as a `MemberId` against a `PlantId`. * Ordering (`<`, `<=`, `>`, `>=`). * Optional operands, including an optional read-model property behind a read path. * Generated or computed values such as `Guid.NewGuid()`, and literals that are not a constant valid Guid string. * Comparing a Uuid with a value of another type. Set `ScreenplayOptions.MaximumExecutableModelVersion` below `SemanticVersion.V28` to withhold Uuid comparisons in both default and authoring output. A located diagnostic names ESM v28 and the cap. Without Uuid comparisons, a document keeps its existing executable version. The rules follow [Screenplay decision 0090](https://github.com/Cratis/Screenplay/blob/main/decisions/0090-admit-uuid-equality-in-command-conditions.md). ## Constraints A uniqueness claim can cover several event types. The generator keeps every readable `On` target under one `constraint`, rather than splitting the claim into independent rules. Multiple direct property selectors become an ordered composite key. Repeated parameterless `RemovedWith()` declarations become `released by` lines, including releases of a generic `Unique` event constraint. Fluent property claims preserve `IgnoreCasing()` as `ignore casing`. The default retention mode, `PerEventSource`, is supported; `WithMode(PerValue)` and nonconstant retention modes withhold the claim because Screenplay cannot express that lifetime. If any target, property selector, release, or retention mode cannot be recovered faithfully, the generator withholds the whole fluent uniqueness declaration with Warning diagnostic `SP0017`. It never keeps only the readable targets or silently drops a release. This includes non-generic event-type arguments, computed selectors, nested property paths, and releases that select particular values: Screenplay can release a whole claim, but cannot express a value-specific release. The generator recovers registration only from unconditional direct builder calls. Builder aliases, helper escapes, conditional assignments and deferred callbacks withhold the declaration with `SP0017` rather than implying that no release exists. Each property callback is read independently of the outer builder chain: chaining `Unique(...)` declarations never merges their targets or releases. Reaction batch admission remains separate from constraint emission. A constraint omitted from the document still affects whether a batch can be stated: unreadable coverage keeps it in code even with atomic mode; below v22, a possible later rejection also prevents equivalence to sequential appends. ## Reactors Every reactor becomes a `reaction` with one `when` trigger per event it handles. What happens under each trigger depends on the handler. A handler whose only effect is the value it returns is stated as what that value sets off, so specifications can run it: ```csharp public class Welcomer : IReactor { public AuthorWelcomed Welcome(AuthorRegistered @event) => new(@event.Name, "Welcome"); } ``` ```screenplay reaction Welcomer when AuthorRegistered produces AuthorWelcomed name = name greeting = "Welcome" ``` The generator only writes this form when it is exactly what Chronicle and Arc do with the returned value: * **`produces`** comes from an event returned by a synchronous handler declared to return that event, or from a literal collection of events returned as a sequence of events or of `object`. Chronicle appends the events to the event source of the triggering event, which is what `produces` says when it names no `for`. A retained batch of two or more events selects [atomic reaction batches](#esm-v22-atomic-reaction-batches), so represented uniqueness constraints no longer force it to stay code. Early stream closure or unreadable constraint coverage still reports `SP0070`; below a v22 cap, legacy per-fact equivalence is required. * **`invokes`** comes from a command returned through `Task` (`Task.FromResult(...)`, or an `async` handler that only returns it), or from a literal collection of commands returned as a sequence of `object`. Arc’s command side effect handlers run each command through the pipeline in order and stop at the first that fails. Without `[ExecuteCommandsAsSystem]`, they run with no caller; with it, the reaction declares the system identity. Chronicle rejects a synchronous handler declared to return a command type, so that shape stays code. * A two-argument `EventForEventSourceId(target, event)` preserves its explicit destination as `for`, whether returned alone, through `Task`, or in a collection. The target must be a required scalar property of the triggering event or unchanged literal text. Computed targets, context destinations, additional constructor arguments, and wrapper initializers keep the handler as code. * The handler takes only the event and, optionally, its `EventContext`. Its body is a single unconditional return of one construction or one literal collection of them. An empty `List` local followed only by `local.Add(expression)` statements and `return local` is recovered too; branches, loops, reassignment, aliases, and other list uses stay code. Every payload value is a property of the triggering event, `context.Occurred` (`$context.occurred`), or a constant. A text constant must be written into the document unchanged, so text with surrounding or repeated whitespace, line breaks, double quotes or backslashes stays code, as do numbers a double cannot hold exactly. * The construction gives every property the event or command carries a value, directly: a positional record property through its constructor argument, or an automatic property with no initializer through the object initializer. A computed property, a property with an initializer or accessor body, a redeclared positional property, an explicit field, or a type deriving from another type keeps the handler as code. * Every event and command it names is declared once in the document, with the properties the mapping fills in. An invoked command receives every one of its properties, and none of them is generated. ### Command refusal branches A returned `Invoke.Command(...)` chain becomes an `invokes` declaration with ordered `on refused` branches. These excerpts assume the event and command types above, and imports for `System.Threading.Tasks` and `Cratis.Arc.Chronicle.Reactors`: ```csharp public class Welcomer : IReactor { public Task Welcome(AuthorRegistered @event) => Task.FromResult( Invoke.Command(new SendWelcome(@event.Name, @event.Country)) .OnRefused(Refused.ByValidation, refusal => new AuthorWelcomed(@event.Name, refusal.Message)) .Acknowledge(Refused.ByAuthorization)); } ``` ```screenplay reaction Welcomer when AuthorRegistered invokes SendWelcome name = name country = country on refused by validation produces AuthorWelcomed name = name greeting = $refusal.message on refused by authorization acknowledge ``` The first matching branch wins, and a handled refusal stops the trigger’s remaining invocations. The generator preserves declaration order; it never sorts or drops branches. Synchronous `CommandInvocation`, `Task` and homogeneous literal invocation collections are supported. | Arc selector | Generated selector | | --- | --- | | `Refused.ByValidation` | `on refused by validation` | | `Refused.ByAuthorization` | `on refused by authorization` | | `Refused.ByAnyConstraint` | `on refused by constraint` | | `Refused.ByConstraint("Name")` | `on refused by constraint Name` | | `Refused.Any` | `on refused` — validation and constraints, never authorization | Named constraints require a constant name that prints unchanged, resolves uniquely to a declared constraint, and targets an event the invoked command produces. Unresolved, renamed, duplicate or shadowed selectors keep the whole trigger as code rather than emitting an invalid branch. A callback must be a pure expression or single-return lambda, anonymous method, or static method group with an available, non-async body. It may return `null` to acknowledge, an event construction, a nonempty literal event array, or a provable two-argument `EventForEventSourceId`. `.Acknowledge(selector)` emits the same acknowledgment. Payloads use the ordinary trigger, occurrence-time and constant mappings; `refusal.Message` additionally maps to `$refusal.message`. `refusal.ConstraintName` (also with `!`) maps to `$refusal.constraint` only under a constraint selector. Both retain the runtime’s verbatim message and first-violated-name semantics. `CommandRefusal.Kind` is an enum, not Screenplay’s lowercase String `$refusal.reason`, so it and its `ToString()` remain code; the generator does not invent a conversion. Branches and refusal values require **ESM v19**, available from Screenplay 4.160.0. A `MaximumExecutableModelVersion` cap below v19 keeps the **whole trigger** file-backed with Information diagnostic `SP0071`, in both authoring modes. An opaque helper, instance method group, conditional callback, side effect, unprovable mapping or route, or invalid production does the same. A branch withheld for batch semantics instead reports `SP0070`, including below v22 when it needs atomic appends. The generator never substitutes an invocation without its refusal handling. Ordinary command scenarios that do not run reactors are omitted only when the reaction actually remains declarative under that cap. See [handling a command refusal](/arc/backend/csharp/chronicle/reactors/command-side-effects/#handle-a-command-refusal) for runtime classification, append failures and recovery limits. ### Identity and observation Identity and observation metadata survive even when the body stays code: * `[ExecuteCommandsAsSystem]` becomes `runs as system`. No attribute means no declared identity; an attribute with zero roles means an authenticated system identity with no roles. The nearest declaration replaces inherited roles. Role text is preserved exactly, including quotes, backslashes, whitespace inside a non-blank role, and Unicode. Null, blank, or duplicate roles retain the source implementation with `SP0064`; none are silently removed. * A direct `[FromEventSource(stream)]` wins over raw source and stream filters and is not inherited. Otherwise, the nearest `[EventSourceType]` and `[EventStreamType]` declarations are resolved separately. Recoverable filters become `from Source` or `from Source.Stream`. An empty raw source and stream `All` mean unrestricted observation; `*` is not a wildcard. A fixed stream without a fixed source cannot be stated. * Observer-only `eventsource` and `stream` declarations come from actual source definitions. No identifier or stream-key type is invented. For const-backed definitions such as Stage renders, `AccountEventSource` supplies model name `Account`, and a direct reference to `AccountEventSource.Notes` supplies stream name `Notes`. The const must be declared on that class. When stored names differ, the declarations retain them with `id "stored-account"` and `id "stored-notes"`. Each name must print and parse unchanged; no alias is guessed from a stored stream value, another owner’s const, or a computed expression. Existing literal identity names retain their spelling. Commands and reducer analysis use the same name recovery; reducer filters remain unstated on their projection counterpart. Unknown, unsafe, reserved, or colliding names retain the reactor implementation with `SP0047`; an undeclared typed stream also reports `SP0048`. Observation is not append routing. `[EventStreamId]` and `ICanProvideEventStreamId` choose where returned events append, not which events reach the reactor. Typed observation alone does not select that append route. Raw source/stream attributes and identity providers affect append routing when returned bare events use the reactor context. If every handler returns only explicit `EventForEventSourceId` destinations, commands, or no results, `[EventSourceType]` and `[FromEventSource(stream)]` remain observation filters (`from Source` or `from Source.Stream`), without an `SP0065` append-route refusal. A provable two-argument explicit destination becomes a production with `for`; an unprovable destination keeps its `file` reference even when the filter is recovered. A bare event or event collection, including one handler in an otherwise explicitly routed reactor, keeps the existing append-route classification. Unproven results keep that conservative classification too. A handler returning bare events with non-default append routing or an identity provider stays a whole `file` trigger with `SP0065`: the released reaction evaluator appends unrouted facts, even though stream syntax exists. Chronicle appends a returned event collection atomically; a Screenplay reaction appends each fact separately. If a later event can be rejected, Screenplay could keep earlier facts that Chronicle would not append. A batch is stated only when no event after the first is targeted by a readable uniqueness constraint, no event before the last closes the stream, and every constraint’s coverage is readable. Coverage includes all generic `On` targets, class-level and property-level `[Unique]`, `[ClosesStream]`, and `ClosesStreamOn`. Constraint configuration such as `PerEventStreamId()` does not make coverage unreadable, and `ReopenedBy` does not add rejection targets. An unreadable constraint, including a visible implementation in a referenced binary, withholds batches but not single-event reactions. Referenced binaries use Roslyn’s public metadata view: internal `IConstraint` implementations are not visible to this scan. Supply that project’s source as an analyzed compilation for complete coverage rather than relying on internal declarations in a referenced package. Reactor scenarios reaching a withheld batch are omitted with `SP0039`. Both metadata directives require ESM v10. A `MaximumExecutableModelVersion` cap below v10 withholds those directives and reports `SP0066` in both default and authoring mode. An otherwise recoverable handler returning only events keeps its `produces` body when only `runs as` is withheld: system identity applies to returned commands, not event appends. Effects that depend on a withheld identity or observation filter retain their source reference. The cap never substitutes an unfiltered or unauthorized executable effect. Anything outside the supported shapes keeps the trigger’s `file` reference: arbitrary control flow, a computed value, a call to a collaborator, any other parameter, an `object` return, a mixed event/command result or routing envelope, a `[Replay]` handler, several handlers for one event, tag filtering, or observation outside the event log. Manual `ICommandPipeline.Execute` outcome inspection or discarded results keep the trigger as code with Information diagnostic `SP0071`, including forwarding the execution task from a non-generic `Task` or `ValueTask` handler; `ReactorDelivery` receipts or `DeliveryId` use report `SP0072`. Recognition uses Arc’s method symbols, not method-name lookalikes. `when redelivered` is a specification action: receipt use cannot prove that a scenario delivers the same recorded delivery again, so it remains opaque rather than becoming a redelivery specification. Conditions are not translated into `where`. `runs as` describes the identity of **returned commands**, not an identity wrapped around imperative pipeline calls inside a handler. A reaction with a `file` body returns `SemanticUnsupported` when a specification reaches it; preserving its metadata does not make that body executable. ## The scenarios a slice is specified by A `.play` says what a slice does. The Chronicle integration specs in the folder beneath it already say the same thing by example — what had happened, the command that was issued, what followed — which is exactly the shape of a Screenplay `specification`. So they are read too, and the document carries the examples proving the model rather than only the model: ```text slice StateChange Registration command RegisterAuthor id String identifier name String produces AuthorRegistered for id name = name event AuthorRegistered name String specification WhenRegisteringAndTheNameIsTaken given AuthorRegistered name = "Jane Austen" given readmodel Author id = "author" name = "Jane Austen" when RegisterAuthor id = "author" name = "Jane Austen" then error "unique-author-name" ``` * **`given`** is what the specification started from — the events it seeded, and the read model it pinned. * **`when`** is its action: a command it executed, or `when append ` for an event scenario’s append. * **`then`** is each event it asserted was appended, **`then error`** a validation or constraint rejection, and **`then denied`** an authorization denial. A fixture’s implicit conversion to a concept must preserve the value you wrote. The generator states a scalar constant or collection element only when it can prove that the conversion operator and its constructor forward the backing value unchanged. An operator that uppercases text, a constructor that changes it, or a conversion whose implementation cannot be proven leaves the whole scenario out with `SP0039`. Expected event values follow the same rule; the generator never removes an element or substitutes the original constant for a transformed value. ### Literal-case tables When body-less concrete classes pass literal values to the primary constructor of one abstract scenario, the generator can state the shared steps once. It reads the base symbolically, reads every row with its constants, and folds only when substituting those values reproduces every row’s complete model: the same steps, outcomes and caller. Textual similarity is not enough. This excerpt assumes `RegisterAuthor` takes a string key and an `AuthorName` concept, and produces `AuthorRegistered` with that same `Name` property: ```csharp using System.Threading.Tasks; using Cratis.Arc.Testing.Commands; using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Chronicle.Testing.EventSequences; using Cratis.Specifications; using Xunit; namespace Library.Authors.Registration.given; public abstract class Registering(string name) : Specification { readonly CommandScenario _scenario = new(); async Task Because() => await _scenario.Execute(new RegisterAuthor("author", name)); [Fact] Task should_append() => _scenario.EventSequence .ShouldHaveAppendedEvent("author", e => e.Name == name); void Destroy() => _scenario.Dispose(); } public class Jane() : Registering("Jane"); public class John() : Registering("John"); public class Empty() : Registering(""); ``` Inside the slice, the generated table is: ```screenplay specification Registering parameter name AuthorName case Empty name = "" case Jane name = "Jane" case John name = "John" when RegisterAuthor id = "author" name = case.name then AuthorRegistered name = case.name ``` The parameter uses the single target type it feeds, not necessarily the constructor’s primitive type. Cases keep their class names and are ordered deterministically. Screenplay expands them into independent specifications such as `Registering_Jane` under ordinary executable admission; tables do not require a new ESM version. An optional read-model target can receive a literal `null` row value; null command and event fixtures remain inadmissible. Rows with added steps or assertions, nonliteral constructor arguments, inconsistent target types, or references outside supported whole value positions stay individual, with Information `SP0076` explaining why they were not folded. Direct parameters are currently recovered in constructed command/event/read-model property values and expected-event equalities. Parameterized callers, clocks, redelivery locators and examples cannot be folded. Existing unreadable-scenario diagnostics still apply; an invalid fixture is not made valid by leaving it outside a table. Parameterized `[Theory]`/`[InlineData]` scenarios are still not stated. Information `SP0076` names each omitted theory: reading an action and its assertions inside a theory method needs a separate reader from the `Establish`/`Because`/`[Fact]` shape. Both command-testing shapes Arc documents are read: the in-process one driving the pipeline through a scenario (`Scenario.Given…`, `Scenario.Execute`) and the one driving a running host (`EventLog.Append`, `Client.ExecuteCommand`). Event scenarios are also read: `EventScenario.When.ForEventSource(...).Events(...)` or a direct append to its event sequence becomes `when append`, not a command. An append action must state one event. Which calls are which is decided by the type each one sits on, so neither testing package has to be referenced for either to be read. ### The caller a command scenario runs as An authorized command’s example needs the same caller as its C# run. The generator reads the [documented caller setup](/arc/backend/csharp/testing/command-scenario/): one stable local or field created with `Substitute.For()`, one `Current.Returns(...)` configuration, and one `Scenario.Services.AddSingleton(accessor)` registration on the same scenario whose `Execute` is the action. The configuration and registration must run unconditionally in `Establish`, before the scenario is initialized. The explicitly typed `AddSingleton(accessor)` form is accepted too. A directly constructed `ClaimsPrincipal` with one default `ClaimsIdentity` becomes `given caller`. A nonempty constant authentication type becomes `authenticated`. Constant `ClaimTypes.Role` claims become `role` lines; other claims become `claim` lines, preserving casing, order within each group, and repetitions: ```screenplay given caller authenticated role "Librarian" claim "tenant" = "a" claim "tenant" = "b" claim "tenant" = "a" ``` Claims must be constructed with two constant strings inside a collection expression, array initializer, or an empty `List` construction with an initializer. A parameterless principal or identity produces an empty `given caller` block. Custom name or role claim types, multiple identities, computed values, unauthenticated identities holding claims, competing authorization services, and extra accessor uses are not recovered. For an authorized command, an unrecoverable or absent caller takes the whole scenario out with `SP0039`, naming the reason. Every referenced policy must also be recovered completely: a partially read or unregistered policy is not proof of the caller’s outcome. Host-driven authorized scenarios are withheld because their caller cannot be recovered from this setup. For a command without authorization, an unsupported caller setup keeps the scenario without `given caller` and reports `SP0075` (Information). No persona is inferred from the claims. Event assertions must state every payload property. `ShouldHaveAppendedEvent` predicates that check only part of an event, or assert only its type, cause the whole command or append scenario to be omitted with `SP0039` (Warning), naming the event and its missing properties. Screenplay compares expected events as whole facts, not subsets; the generator does not fill the gaps from command inputs or defaults. Screenplay 4.98.0 permits omitted optional properties, but the generator conservatively omits these partial scenarios as well; an explicit `null` is not a valid specification event value. Complete predicates retain their stated values. This check also applies to event assertions after `when append`, before any assertion that merely restates the appended fact is removed. A generated scenario must say the same thing as the code. Screenplay assumes a source-less command scenario’s `given` events belong to the command’s own source. An event seeded or asserted on another source must state that source as an indented `for ` on its `given`, event `then`, or `when append` block. If the generator cannot state it faithfully, it omits the whole scenario with `SP0039` (Warning), including validation-only and authorization rejections. A command without an emitted identifier is not an exception: explicit sources cannot be collapsed into one implicit source unless they are provably the same as the command’s source. Concrete sources can be stated only when every producing command retains the same required scalar identifier type and the values fit that type. A `Uuid` destination requires a canonical lowercase GUID string. Stable fields and properties with a single constant initializer are followed one hop; equal values, including constant-wrapped identities, remain implicit when they match the command’s source. String event-source ids are compared exactly, including casing and braces; only values converted to GUIDs at runtime are normalized to `Guid.ToString("D")`. A positional identifier property read from the same unreassigned held command passed to `Scenario.Execute` also remains implicit. An event scenario sharing one source can also remain implicit when that source cannot be typed. Distinct or undecidable sources that cannot be stated take the scenario out with `SP0039`; separate calls to `EventSourceId.New()` are not the same source. A direct event-sequence append supplying concrete source and stream names becomes `stream .` by default, with `streamId = ` when stated. An unrouted given or append retains its other values without a route directive, with `SP0060` (Information) reporting the omitted metadata; an unrouted expected fact states `no stream`. Expected events can carry the issued command’s route when every production demonstrably uses command context and the stream-id fixture is concrete. A source identity remains a separate `for` value, not payload data. Routes must fit the source and stream types the document declares. Computed routing, unproven composite stream-id construction, nonportable literals, or incompatible types retain `SP0039` rather than a guessed route. ESM v8 admits routes on `given`, `when append`, and `then`; the generator withholds a routed scenario with `SP0060` only when default output is capped below v8, or its source has the reserved name `Default`. An unconditional `result.ShouldNotBeAuthorized()` on the stable result of the scenario’s `Execute` becomes `then denied`. The command must have an emitted `authorize` gate, every referenced role or named policy must be recovered exactly, and the scenario must supply an exactly recovered `given caller`. The standard authentication-only gate is also supported. A partial or synthesized named policy is not proof of denial. Before emitting denial, the generator runs an authorization-only probe through the pinned Screenplay reference runner with the emitted policies and caller. The probe must produce the typed `Unauthorized` outcome, not a validation error. If the caller would pass, `SP0039` reports “stated caller satisfies the stated authorization”. A policy needing a subject, artifact value, or unrecovered predicate cannot prove failure from the caller alone; `SP0039` reports “denial cannot be decided from the stated caller”. A denial cannot accompany event, read-model, response or error assertions. An unconditional `ShouldNotBeSuccessful()` on the same command result adds nothing to denial and may accompany it. No separate `then no events` is emitted: Screenplay does not allow that form beside `then denied`. Other assertions that cannot be proven redundant withhold the scenario. `SP0039` names the reason: caller not recovered, policy not recovered exactly, denial combined with another outcome, or “denial not explained by any stated policy” for a command without an emitted gate. Conditional denial assertions, unrelated helpers with the same name, and assertions on another or reassigned result also retain `SP0039`. A command whose visible code can return `AuthorizationResult`, `CommandResult`, a wrapper carrying either, or an untyped result is also withheld: code-level denial from `Provide()`, `Handle()`, or a visible helper is not explained by the stated gate alone. `ShouldHaveExceptions()` remains unsupported. `ShouldNotBeSuccessful()` and `IsSuccess.ShouldBeFalse()` alone do not identify a validation, constraint or authorization category and retain `SP0039`; beside a separate validation assertion they add no reason. A rejection the source asserts without naming a reason is written as bare `then error`, not `then error ""`. This includes `IsValid.ShouldBeFalse()` and `ShouldHaveValidationErrors()`. The bare form binds and matches a validation or constraint rejection without asserting its message. The source gives no code or presentation message, and inventing either would put meaning in the document the application never states. A scenario that compares the command response with concrete values becomes `then returns`: ```csharp [Fact] void should_return_the_name() => ((CommandResult)_result).Response.ShouldEqual("Apollo"); ``` ```screenplay specification WhenEchoingAndANameIsSupplied when EchoName name = "Apollo" then returns "Apollo" ``` The generator reads `ShouldEqual` and xUnit’s `Assert.Equal`, applied unconditionally to `CommandResult.Response`, or to one field of a record response, and compared with a literal, a `string`, `int`, `decimal` or `bool` constant, `Guid.Parse("...")`, an enumeration member, or a concept constructed directly from a literal or `Guid.Parse("...")`. A whole record compared with a construction of every field becomes the full field list. Field assertions become a `then returns` block holding only the asserted fields. `T` must be the type the handler returns, and the result must be the variable the scenario’s own `Execute` call is assigned to, written nowhere else. A write through any member or element reached from that result (assignment, `++`/`--`, `ref`/`out`), or copying the result or a non-string reference value read through it into another variable or returning it from a member or lambda, also keeps the response from being stated. The null-forgiving `result!` is a read, not a write. Mutation through ordinary method calls is not analyzed. Every read of `Response` in the scenario must be such an equality. A null check, a comparison with a value computed during the run, a result from somewhere else, or an assertion under a condition, after an early `return`, or inside a `try` with a `catch` cannot be stated, and nothing about the response is stated in that case. A scenario left out this way does not hold back the command’s named rules. What happens to a scenario asserting the response: | The scenario | The command | Result | | --- | --- | --- | | States the response and other outcomes | Emits `returns` | The scenario keeps its events and gains `then returns`. | | Asserts the response in a form that cannot be stated, beside other outcomes | Has a response | The command keeps its legacy form (`SP0052`); the scenario is kept and the response assertion ignored, as in the legacy document. | | Has the response as its only outcome | Emits `returns`, has no productions, and its handler can return no event: no event, tuple, collection, `Result` or union holding one, and no `object` or interface | The scenario is stated with `then returns` only. | | Has the response as its only outcome, or asserts it in a form that cannot be stated | Records facts, or emits no `returns` | The scenario is omitted with `SP0039`, because a specification without expected events asserts that none were produced. | A scenario that does not assert the response does not hold the response back. For `[GeneratedValue]` parameters, `PinGeneratedValue` supplies the deterministic fixtures Screenplay needs: indented `for ` for a generated identifier and `generated = ` for other generated values beneath `when`. Literal UUID and strategy-shaped text pins, including stable held fields and pins explicitly targeting the issued command type, are recovered. Missing or unreadable fixtures omit the scenario with `SP0039` and leave the command’s generation contract intact. `New()` pins, `AllowGeneration()` and nested-command pins cannot be stated as concrete fixtures. See [Recovering generation strategies](#recovering-generation-strategies). Legacy values created inside `Handle()` still cannot be pinned. Their commands with successful scenarios keep legacy productions or handler references without `generated` or `returns`, and `SP0052` says why. Explicit given event sources also preserve this legacy fallback. Validation and authorization rejection scenarios need no generation fixture because those checks precede generation. A cap below ESM v7 withholds responses and `then returns` as well. A scenario whose sources cannot be stated faithfully is omitted regardless of fallback. Expect the document to grow. On a real application this roughly doubled it, at about seven lines per scenario. ### Reactor scenarios and the reactions a scenario sets off Since ESM v6 a specification runs the reactions its facts set off, and its `then` events are every fact that follows: the action’s and the reactions’. Arc’s `CommandScenario` and `EventScenario` run no reactor. A command or append scenario whose facts set off a reaction stated with `produces` or `invokes` would therefore state less than the document runs, so it is omitted with `SP0039`. A rejected command records no facts and is kept. A scenario reaching a reaction that is still a `file` reference is kept as before. A Chronicle `ReactorScenario` of a reactor stated declaratively becomes a specification of its reaction. The event it delivers becomes `when append`, and each `ShouldHaveProduced(...)` becomes a `then` event, in the order the reaction produces them: ```csharp public class and_the_author_is_welcomed { readonly ReactorScenario _scenario = new(); async Task Because() => await _scenario.Given.ForEventSource(EventSourceId.New()).Events(new AuthorRegistered("Jane Austen", "UK")); [Fact] void should_welcome_the_author() => _scenario.ShouldHaveProduced(e => e.Name == "Jane Austen" && e.Greeting == "Welcome"); } ``` ```screenplay specification WhenAnAuthorIsRegisteredAndTheAuthorIsWelcomed when append AuthorRegistered name = "Jane Austen" country = "UK" then AuthorWelcomed name = "Jane Austen" greeting = "Welcome" ``` The scenario is kept only when it says what its reaction does, and no more: * Its assertions are `ShouldHaveProduced(...)` and `ShouldNotHaveProduced()` and nothing else. A predicate is a conjunction of equalities with constants, and each event is stated whole: every property gets a value. * It expects every event the reaction appends to the triggering event’s source, each once. `ShouldNotHaveProduced()` must name something the reaction does not produce. * The last event it delivers is the one the reaction handles. Earlier events become `given` only when the reactor does not handle them, because every event a reactor scenario delivers runs the reactor. * It seeds no read model, the appended event sets off no other reactor, and nothing the reaction appends sets off a reaction. * Its observation is unrestricted. A filtered reactor requires a routed occurrence fixture; the generator does not infer that fixture from the filter or replace it with an unrestricted append. A reactor scenario that asserts something else has no counterpart and is reported with `SP0043`. That covers a collaborator mock, `Produced` read directly, a scenario of a reactor whose handlers are all code, and `ShouldHaveProduced()`. Screenplay states the facts an invoked command records, while the reactor scenario only records the command; those facts stay omitted unless the reactor specification’s own source evidence proves them, and the command’s scenarios alone are not enough. A reactor scenario that does have a counterpart but cannot be stated faithfully is omitted with `SP0039`, which names the reason. ### A scenario is read whole or not at all An optional event payload mapping can be left out while the rest of its block still says something true. When dependency pruning would remove a required payload mapping or leave a condition reading an unavailable read, the whole production is withheld instead. A scenario cannot: an example missing the state it started from, the action it performed, or the outcome it expected is not that example — it is a different one nobody wrote. So a step that cannot be read takes the whole scenario with it, and `SP0039` names the scenario and what made it unreadable. A value the document cannot hold takes the scenario with it the same way. The document names an enumeration member, and a scenario value that is none — a number cast to an enumeration that declares no member with it, `default` for an enumeration with no zero member, `null` for a required record or list, or several `[Flags]` members combined into a value no member is declared with, because Screenplay has no form to state combined flags — would be written as a different example if it were dropped or invented. `SP0039` names the property, the value and why. The type-extraction warning `SP0067` also reports the flags limitation before any scenario uses an unnamed combination; see [Enumerations](#enumerations). Conditional steps are the common case. Anything written under an `if`, a `switch`, a loop, a ternary or a lambda happened in some runs of the specification and not in others, and the source text does not say which — so a step recovered as unconditional would state a world nobody specified. That is the one failure mode a reader has no way of catching, which is why it is reported rather than guessed at. A step need not construct what it states where it is written. A specification routinely holds the event or the command in a member and names that member in the step — `Scenario.Given.ForEventSource(id).ReadModel(TargetUser)`, `Scenario.Execute(_command)` — because the same value is asserted on later, or because it is built where the values it needs already are. Such a member is followed **one hop**, to the single place it was put together, and the step reads exactly as the inline form does. A `= null!` or `= default` declaration is not one of those places: it exists to satisfy the compiler and states no value. One hop, and only from one place. A member given a value twice, or given it under a condition, held different values in different runs and the source does not say which one the step saw — so it stays unread and the scenario is left out with `SP0039`, the same as any other conditional step. Following a chain would mean reasoning about what a value was at the moment the step ran, which is a different discipline from reading what was written. Values are the exception for the compatibility document generator, because a value stands on its own the way a mapping does. They follow [the discipline every other value follows](#a-value-is-only-ever-what-the-source-states): the identity two steps agree on is routinely a fresh identifier held in a field rather than something written down, and such a value is reported on its own while the rest of the legacy scenario stands. ### Neutral specification facts are stricter `ArcSpecificationFactAdapter` is the independently consumable source-evidence surface. It contributes Generation scenario, ordered-step, and typed-value facts only when every explicitly authored step and value is exact. One computed or unreadable required value, conditional/repeated step, unretained read-model assertion, or event predicate value blocks the whole neutral scenario with `ARCSP0001`; it never contributes a smaller example than the source wrote. The adapter retains scenario-, step-, value-, and rejection-level source ranges separately from the existing Arc model. This does not change legacy model equality or the current `.play` generator’s compatibility output. It creates one fixed `DotNetSourceStructures` snapshot and asks `DotNetSourcePlacementDerivation` to place all exact targets together. Application and specification projects therefore use the application target’s source placement, read-model and query targets retain `StateView` placement, and folder/namespace disagreement or invalid source policy produces a typed `DOTNETSP####` diagnostic instead of a guessed placement. Generation attaches a scenario only through that exact target placement and lowers it only after complete atomic admission. This distinction is deliberate: the compatibility generator keeps existing consumers stable, while the neutral adapter provides the fail-closed evidence required for render→recover semantic fidelity. ## Screens A [vertical slice](/arc/vertical-slices/) puts the React component that realizes a slice’s screen in the same folder as its C#. Roslyn syntax trees carry real file paths, so the generator knows where each slice’s source lives and declares a `screen` for every `.tsx` component sitting next to it, named after the file: ```text slice StateView Listing query AllAuthors => Author[] query AuthorById(id : String) => Author screen AuthorList file Authors/Listing/AuthorList.tsx data Author[] via query AllAuthors data Author via query AuthorById by id ``` What is recovered about a screen comes from something that can be checked. **The `file` reference** says which file realizes the screen. It is what a reader opens, and no directive replaces it, so it stays on the screen even when directives sit beside it. **The `data` directives** say which queries the screen reads through — the slice’s own, or another slice’s under a qualified name. Arc generates a TypeScript proxy per query and a component imports that proxy by name, so the component’s `import` statements name candidates — and a candidate is kept only when the slice its import points at really declares that query. Nothing about the binding comes from the component beyond that name: the read model, whether there is one or many of it, and the parameter it is keyed by all come from the C# query. An import naming anything else — a package, a command, a sibling component, a type-only import — leaves nothing behind. **`title`, `table` and `action`** come from the [Cratis Components](/components/) the screen uses, because their properties mean the same thing in every application. Only components imported from `@cratis/components` are read: | Component | Becomes | | --- | --- | | `DataPage` `title="…"` | `title "…"` | | `DataPage`, `DataTableForQuery` or `DataTableForObservableQuery` with `query={…}` naming a query of the slice or, qualified, of another slice | `table `, named after the read model the query returns | | `Column` `field="…"` inside such a table | `column `, with `label "…"` when `header="…"` is text | | `CommandDialog` with `command={…}` naming a command of the slice or, qualified, of another slice | `action `, unless a toolbar item already exposes that dialog | | `DataPage.MenuItems` / `DataPage.MenuItem`, or imported `MenuItems` / `MenuItem`, opening a command dialog of the slice or, qualified, of another slice | `toolbar actions` with `item action ` and a literal `label`, when the opener can be proven | Take a listing screen written with a data page: ```tsx import { DataPage } from '@cratis/components/DataPage'; import { Column } from '@cratis/components/DataTables'; import { AllAuthors } from './AllAuthors'; export const AuthorList = () => ( retire()} /> author.name.slice(0, 1)} /> ); ``` It is generated as: ```text screen AuthorList file Authors/Listing/AuthorList.tsx data Author[] via query AllAuthors title "Authors" table Author column name label "Name" ``` A property is read only when it is written as text, or as an imported query or command declared by a slice. Anything else is left out rather than guessed, and `SP0028` reports each omission on its own: a computed title or header, a column without a `field`, an unread menu callback, a table or dialog whose imported query or command cannot be resolved, and a component whose properties are spread in from an object. Above, the `Initials` column and the `Retire` menu item are each reported. Comment markers inside quoted strings, template text, JSX attributes and JSX text stay literal: `//` in a URL does not cut off the rest of a component. The reader scans lexical boundaries, not all of TypeScript; ambiguous type-position JSX or a regex after a closing parenthesis or brace can fall outside its supported shapes. If a file importing recognized components cannot be scanned, no structure is inferred and one `SP0028` names the line and construct. A menu item becomes a toolbar action only when its `command` is exactly `() => show(true)` or `() => { show(true); }`, with exactly one React `useState` tuple for that setter connecting it to the `visible` property of one readable `CommandDialog`. Other setters may have their own tuples and dialog openers. The tuple, setter reference and visibility reference must be in the same enclosing component function, in directly returned JSX without intervening parameter or local shadowing. The reader supports block-bodied named functions and parenthesized arrow functions; scopes it cannot prove get `SP0028` rather than an item. Named `useState` imports must be unaliased and come from `react`; `React.useState` requires a React default or namespace import. Within that component body, the hook or namespace name must occur only in recognized tuple calls, so local hook rebinding or other uses that the reader cannot prove also withhold the item. The dialog’s command may belong to another slice. The `MenuItems` container must itself be an unconditional direct child of `DataPage` and contain only unconditional direct item markers; spreads, conditional or mixed children, wrappers, and disabled items are reported rather than emitted. An orphan `MenuItem` outside a `MenuItems` container renders nothing and gets its own `SP0028`. A computed item label is reported with `SP0028` and the proven item is emitted without a label. Icons are omitted. The toolbar is named `actions` for a single page; multiple pages use read-model-based names or positional suffixes, and repeated item names get numeric suffixes. A claimed dialog does not also create a screen-level action, but another unclaimed dialog for the same command still does. Input opening is not post-success navigation. A resolved `CommandDialog onSuccess` navigation becomes the command action’s after-success destination, never navigation attached to the input-opening item. An unread success callback gets its own `SP0028`. See [Navigation](#navigation) for the route-table shapes that prove a destination. `detailsComponent` is a selected-row pane on the same screen, not navigation. It reports Information diagnostic `SP0077`, as do a screen opened through `useDialog` from `@cratis/arc.react/dialogs`, computed `disabled`, and a recovered item’s `disableOnUnselected`: Screenplay has no corresponding composition or toolbar enablement. The MVVM `useDialog` takes a request type and a component, so it is not treated as the single-component hook. Computed `disabled` withholds the toolbar item and leaves a readable dialog command as a screen action; `disableOnUnselected` on a recovered item only reports that its enablement is not stated. A details file beside the slice still appears as its own screen. The router’s route table supplies navigation destinations; same-screen composition does not. Cross-slice screen actions, toolbar actions, navigation destinations and `data … via query` bindings use qualified references. The generator resolves the proxy import’s relative module path to the source directory of the slice declaring that command or query; a matching name in another directory is not enough. It writes the shortest trailing scope of the emitted module/feature/slice path that uniquely identifies a declaration of that name and kind. For example, a listing screen importing `../Registration/RegisterAuthor` writes `action Registration.RegisterAuthor`. If two features each declare that command in a `Registration` slice, it writes `action Authors.Registration.RegisterAuthor`. Own-slice references stay bare. A component binding with an unresolvable module path, an import outside any slice directory, or a name not declared by the imported slice still gets `SP0028`. A reference whose declaration is withheld during emission, or whose emitted scope is ambiguous even when fully qualified, is also omitted with `SP0028`. Unmatched imports alone do not establish query bindings; `SP0036` is reserved and no longer reported. Everything else is **never inferred**: `section`, `summary`, `layout`, and structure outside the recognized component and navigation shapes. A details component is a pane on the same screen, so it does not establish navigation. A screen that uses none of the components reports `SP0028` once. In a mixed screen, markup outside recognized component spans is also reported: once per distinct component tag, then one report listing distinct HTML tags. This check covers the whole file, including helper components. Content inside a recognized component stays covered by that component’s own reports, so dialog inputs do not create extra reports. Fragments without direct content and intrinsic wrappers around recognized components with no direct text or expression children are exempt. A guessed column is worse than an absent one: it puts a confident falsehood into a document whose entire value is that it describes the real application. Write those directives by hand if you want them, and expect a regeneration to leave them out. Two more rules keep the result honest: * **Components only, no descending.** A file carrying a second extension — `AddAuthor.stories.tsx`, `AddAuthor.spec.tsx` — is a companion of a component, not a screen. A folder *inside* a slice folder is a slice of its own under the same convention, so its files belong to it. * **Anything uncertain is reported.** The relationship between a file and a slice comes entirely from where the file sits, so `SP0025` is reported whenever sitting there says less than usual: a slice whose source is spread over several folders, one folder holding the source of several slices, or two files claiming a single screen name. ### Navigation A literal router gesture can identify a destination without guessing from a URL’s spelling. The generator reads TSX files importing named `Route`, `Routes`, `createBrowserRouter`, `createHashRouter`, or `createMemoryRouter` bindings from `react-router` or `react-router-dom` under the common root of each compilation’s source paths. Discovery is recursive, including nested `.frontend` folders. It skips `node_modules`, `bin`, `obj`, `dist`, `wwwroot`, other hidden directories, and file or directory symlinks, including when an excluded directory is a search root. The normal slice-directory rule still determines which files are screens; finding a route table does not turn it into a screen. Supported tables use nested `}>` declarations inside ``, or direct `createBrowserRouter([...])`, `createHashRouter([...])`, or `createMemoryRouter([...])` calls with literal route objects. Relative child paths join their parent; absolute paths retain their prefix; path-less layout routes pass through the parent path; an `index` route uses that parent path. Router imports may be aliased. The element must be a sole JSX component imported by a named relative import. Its resolved module file, not its component name or the route’s spelling, must match exactly one retained screen file. Two gestures use that table: * A menu item’s `command={() => navigate('/literal')}` becomes `item navigate to `, preserving a literal label. * A `CommandDialog onSuccess={() => navigate('/literal')}` becomes `navigate to ` under its command action. If a toolbar already opens that dialog’s input, the separate action carries success navigation. Dialogs exposing the same command must agree on the destination screen, route pattern, and every parameter key and value; conflicting success behavior is reported rather than combined. The callback must use a `const navigate = useNavigate()` binding imported from either router package in the same directly returned component body, without hook or parameter shadowing. Only absolute, quoted literal destinations are matched. Required whole-segment route parameters such as `:id` become `parameter id from literal "42"` when the gesture supplies `/authors/42`. A destination in another slice uses the same shortest unique emitted scope as other cross-slice screen references. For example, an application’s `App.tsx` can declare: ```tsx import { Route, Routes } from 'react-router-dom'; import { AuthorDetails } from './Authors/Details/AuthorDetails'; export const App = () => ( } /> ); ``` This screen excerpt assumes the `RetireAuthor` command proxy and the destination screen already exist in their respective slices: ```tsx import { DataPage, CommandDialog } from '@cratis/components'; import { useNavigate } from 'react-router-dom'; import { RetireAuthor } from './RetireAuthor'; export const AuthorRetirement = () => { const navigate = useNavigate(); return navigate('/authors/42')} /> navigate('/authors/42')} /> ; }; ``` With the screen in `Authors.Retirement` and its destination in `Authors.Details`, the generated directives are: ```text screen AuthorRetirement file Authors/Retirement/AuthorRetirement.tsx title "Authors" toolbar actions item authorDetails navigate to Details.AuthorDetails label "Details" parameter id from literal "42" action RetireAuthor navigate to Details.AuthorDetails route "/authors/:id" parameter id from literal "42" ``` An action’s navigation retains the matched literal route-table pattern as `route "…"`, including its parameter names; the gesture supplies the parameter values. The pinned Screenplay toolbar-item grammar accepts parameters but not a `route` option, so toolbar items omit that option. Duplicate literal paths, wildcard or optional paths, computed paths or elements, lazy components, `` redirects, non-default `caseSensitive`, a `Routes location` override, spreads, and route children written outside the literal table shapes do not establish destinations. Unread declarations remain competing route claims: a destination maps only when every declaration that could match it is known and exactly one readable route matches. An unread subtree blocks its parent prefix and descendants; an unread path with no known scope blocks all destinations. A table is a root only when it is lexically inside a rendered `BrowserRouter`, `HashRouter`, `MemoryRouter`, or `RouterProvider`, is passed directly to one of the supported router factories, or is the only route-declaring file in the entire scan. An entry point that only wraps an imported `` in `BrowserRouter` does not count as another route-declaring file. Every other table has an unknown mount prefix: its route claims block any root path whose trailing segments could match under any prefix, including parameterized paths. Unread unknown-mount subtrees block conservatively. Root detection never follows application imports, so aliases, barrels, comments, and dynamic imports cannot make a nested table look like an absolute root. Multiple `` containers in one file also have unproven mount prefixes. One `SP0028` per route-table file lists what was not read; an unresolved gesture still gets its own `SP0028`. Matching more than one route pattern also withholds navigation rather than choosing a destination. A missing table leaves the previous omission reports unchanged. ``, inline JSX `onClick` navigation outside menu items, row-click navigation, relative destinations such as `navigate('..')`, template literals, variables, and navigation with state or options remain reported, not inferred. Encoded destination segments, query strings, and fragments also remain unmatched. These require gesture or URL readers beyond literal route-to-screen resolution. ## What is not expressed A `.play` is a description of an application, not a second copy of it. **It does not round-trip back to code**, and reading one will not tell you everything the code does. The losses run in both directions. ### Screenplay constructs the generator cannot infer These are part of the language, but nothing in C# says them, so a generated document never contains them. Add them by hand if you want them, and expect a regeneration to leave them out: | Construct | Why it cannot be inferred | | --- | --- | | `capture` | Describes ingesting an external system. Nothing in an Arc application declares one. | | `persona` | Who uses the system is a product decision, not a code artifact. | | `seed` | Sample data is a modeling concern, not something the source states. | | The rest of the body of a `screen` — `section`, `summary`, `layout`, and structure outside recognized components and navigation gestures | What a screen *shows and does* is JSX. Its `file`, its `data` bindings and what known Cratis Components state are generated; the rest would be a guess — see [Screens](#screens). | | Compliance reasons and personal-data qualifiers | Bare `pii` and `secret` are recovered from `[PII]` and concept-level `[Encrypted]` plus `[NotAudited]`. The generator does not infer reasons, special categories, or criminal-data classification. Non-default secret scope is emitted only in authoring mode; otherwise `SP0061` reports its omission. | ### Detail Screenplay cannot represent These details can remain outside the document because the language has no counterpart, the source cannot be read reliably, or an authoring-only construct is disabled. Diagnostics explain what the document leaves out. | In Arc or Chronicle | Why it is not in the document | | --- | --- | | Event generations, `[Tombstone]`, `[CompensationFor]` | Screenplay describes the current shape of an event. It has no notion of versioning, of a deletion marker, or of one event compensating another. `SP0014`. | | Same-screen `detailsComponent`, screens opened through `useDialog`, toolbar selection or computed enablement | Information diagnostic `SP0077` distinguishes missing Screenplay composition or enablement from unread callbacks. No navigation, dialog template, or component identity is invented. | | Unread menu callbacks, proxy imports, route tables and dialog `onSuccess` | `SP0028` reports unsupported behavior separately from input-opening gestures. Literal menu and success navigation maps only through unambiguous route-table screen imports; parameters come from matching literal path segments. Duplicate, lazy or computed declarations are listed once per table. Missing tables, unresolved bindings, Link targets, inline clicks, row-clicks, relative or computed destinations, and navigation options remain reported. | | Screen markup outside recognized Cratis Components | `SP0028` per distinct component tag, plus one list of HTML tags. Fragments and pure wrappers are exempt; unscannable TSX reports once and yields no inferred structure. | | Reducer folds (`IReducerFor`) | The fold is code. The read model and the events it observes are recovered; the logic that combines them is not. `SP0020`. | | Unrecoverable reactor observation filters | Recoverable filters are emitted as `from`, including on file-backed triggers. Missing, ambiguous, unsafe, or unexpressible definitions keep executable effects in source rather than broadening observation. `SP0047` explains the refusal; `SP0048` reports an undeclared typed stream. Reducer filters remain unstated on the projection counterpart. | | Invalid returned-command system identity | Null, blank, or duplicate roles retain the source implementation with Warning diagnostic `SP0064`; roles are never sanitized. | | Routed reactor event results | Provable two-argument `EventForEventSourceId` destinations become `for`. Extra wrapper metadata stays code. Context append routes and identity providers retain the whole trigger with Warning diagnostic `SP0065`, independently of recoverable observation metadata. | | Unrecoverable reactor command refusal handling | Pure Arc `OnRefused` and `Acknowledge` chains emit ordered branches at ESM v19. Opaque callbacks, unproven selectors or mappings, a cap below v19, and manual inspection or discarding of `CommandResult` after pipeline execution retain the whole trigger as `file` with Information diagnostic `SP0071`. | | Receipt-based reactor redelivery | `ReactorDelivery` or `DeliveryId` use retains `file` with Information diagnostic `SP0072`. Receipt use alone cannot recover a same-delivery `when redelivered` specification action from code. | | Unreadable fluent uniqueness targets, lifetimes or registration | The whole constraint is withheld with Warning diagnostic `SP0017`, rather than stating a subset. Non-generic event-type arguments, computed or nested selectors, value-specific releases, non-default or unknown retention, builder aliases and unproven control flow cannot be stated faithfully. | | Returned event batches whose semantics are not admitted | A rejecting constraint absent from the document (including class-level `[Unique]`), early stream closure (pending [Screenplay#770](https://github.com/Cratis/Screenplay/issues/770)) or unreadable constraint coverage keeps the whole trigger as `file` with Warning diagnostic `SP0070`. Below v22, a possible later rejection does too. At v22, recoverable multi-production batches use `reaction batches atomic`, including refusal branches. | | Reaction metadata below an ESM v10 cap | Warning diagnostic `SP0066` reports withheld metadata in both default and authoring mode. Effects depending on that metadata retain their source; event-only productions remain declarative when only `runs as` is withheld. | | Aggregate roots no command reaches | The events an aggregate root applies are stated through the command that hands its work to it. One that nothing calls has nothing to state them through — a document has no construct for a class that decides on its own. `SP0018`. | | A behavior deciding on the state an aggregate root holds | A `produces when` condition compares the input of the command, which is all a document knows at the moment the command is issued. A behavior refusing to act on what it has already seen is a real decision with nowhere to go, so the event is stated unconditionally and `SP0027` reports the decision. A behavior deciding on one of its own *parameters* is recovered, because the call site says which command input that parameter was given. | | Inline `policy` code and requirements built in code | Unreadable `RequireAssertion(…)` bodies and a policy registered from an `AuthorizationPolicy` built elsewhere remain in code. Guarded claim-target assertions described above, `RequireAuthenticatedUser`, `RequireRole`, and `RequireClaim` given the values it accepts are recovered; the rest is reported as `SP0026` — including a `RequireClaim` naming only a claim type, which a policy condition has no way to state. | | The event source id from a `(TKey, TEvent)` handler | The event is recovered. An admitted generated UUID or String identity and response are stated by default. If the destination cannot be proven or the generated concept has validation, no destination is inferred and `SP0013` explains the omission. | | Emptying a scope with `[ClearWith]`; removing a child with `[RemovedWith]` on the property holding it | Nothing in the model a projection is built from carries a scope being emptied again, so `[ClearWith]` has nowhere to go (`SP0015`). A removal does have somewhere — but it is read from the type of the child, alongside the events filling that child in, so the same removal written beside the collection is reported as `SP0007` instead. | | Read model tags | A `readmodel` declaration states the shape of a read model — its properties, a description, and the file declaring it — and nothing else, so there is nowhere to hang a tag. Tags on *events* are recovered and written out. `SP0042`. | | Read models the document cannot declare | Two read models sharing a declaration name, a name a concept or type already uses, a value with no faithful Screenplay type (no type at all, nullable collection elements, or a type whose name another type already declares), or a read model no slice refers to. The read model is left undeclared rather than described with a shape the application does not have. Information diagnostic `SP0057`. | | Query paging and sorting, custom routes | These say how a model is served rather than what it is. The parameters the host fills in are left out, and a route template — `[Path]`, `[Route]`, or a template on an HTTP verb — has no counterpart. `SP0041`. | | Several command identity candidates | The generator cannot choose between key properties or event source identities. No `identifier` is emitted. Information diagnostic `SP0049`. | | An unreadable command identity | A self-provided identity must directly return a required scalar command property; an optional or collection key cannot be an identifier. Information diagnostic `SP0050`. | | An unprovable production destination | Proven returned wrappers and identity tuples can emit per-production destinations and effective routes. Unproven routing, key-only fan-out without an admitted stream/generated identifier, and overrides below an ESM v10 cap retain the conservative fallback instead. Information diagnostic `SP0051`. | | Generated values and responses outside supported shapes | Admitted values and responses are emitted by default. A generated value needs a required scalar generatable concept with completely recovered, non-opaque validation and no compliance markers; rule-free non-default strategies require ESM v15 and validated strategies require ESM v20; a response must be a direct property or fully readable record. Unsupported shapes and pre-generation references remain in code. Information diagnostic `SP0052`. | | Unrecovered `[GeneratedValue]` strategies | Supported UUID, ULID, TypeID, NanoID and byte strategies are recovered. Rule bodies, context-member use, pattern/random and invalid literal arguments keep the legacy command with located Information diagnostic `SP0073`. A cap below v15 withholds non-default concept generation; a cap below v20 withholds validated generation without deleting its rules. Refuted rules report `PLAY0688` with a witness in `SP0073`. Non-opaque unproven rules remain stated with Information `SP0078` carrying `PLAY0689`. | | Unrecoverable generation pins | Successful `[GeneratedValue]` scenarios need literal UUID or strategy-shaped text pins for every generated parameter. Missing, mismatched, computed, conditional, repeated or nested-command pins and `AllowGeneration()` omit the scenario with Warning `SP0039`; the command keeps its admitted generation contract. | | Unreadable returned operations | Operations are authoring-only. Unsupported construction, input mappings, batch spreads, or ambiguous external systems remain in code; readable literal batch members can still be described. Information diagnostic `SP0053`. | | Event-source definition routes | Readable source and stream routes are emitted by default at ESM v8. `SP0044` describes an unavailable route or preserved legacy concurrency; a cap below v8 retains the previous default output. | | Unreadable source or stream routes | Computed stream ids, unproven formatting, generated keys, conflicting declarations, reserved classifications and unsupported wrapper metadata stay in code. An unproven or capped production override also withholds the command route rather than claiming the command’s defaults for that event. Information diagnostic `SP0054` reports the omission in both modes. Admitted production overrides require ESM v10; default routes require v8. Property-path mappings and handler-command routes remain authoring-only. | | Unreadable reads or provisioning | Advisory reads and their requirements are authoring-only; [protected reads](#protected-command-reads-and-esm-v25) are executable. Dependencies need a proven input key and a uniquely readable model and projection; only supported comparison guards become requirements. Other provisioning remains in code. Information diagnostic `SP0055`; a protected read left in code is reported as `SP0079`. | | Same-named concepts with different meanings | Primitive type, generation strategy, enumeration names and numbers, `pii`, `secret`, and encryption scope must agree. The first declaration is kept; warning diagnostic `SP0031` names each conflicting type rather than silently merging its compliance claims. | | Protected concepts in emitted identity positions | The identifier, route or event-production destination remains stated, but its concept’s `pii`/`secret` annotations are omitted globally. Information diagnostic `SP0059` names each retained use. An analyzed identity that is not emitted does not cause this omission. | | Namespace or Global encryption scope in default output | Bare `secret` implies Subject scope. Information diagnostic `SP0061` names the concept and omitted scope. Enable `ScreenplayOptions.AuthoringOnlyConstructs` to state it. | | Member `[PII]` on a non-concept type | Screenplay classifies only concepts. Information diagnostic `SP0062` names `Type.Member` and suggests a personal-data concept; none is invented. | | Member `[PII]` promoting unmarked concept uses | The concept remains `pii` everywhere. Information diagnostic `SP0063` names the concept and lists unmarked analyzed uses that acquire this classification. No diagnostic is reported if the concept itself is marked or every analyzed use is marked. | | Unnamed `[Flags]` combinations | Members remain declared individually. Warning `SP0067` names the enum and property uses; named combinations are ordinary members. No warning is reported when every positive-member combination is named. | | Enum numbers outside int32 or above the ESM cap | Numbers are withheld together and members fall back to positional numbering. Warning `SP0068` lists the numbers and the range or cap limitation; stored identity is not preserved. Emitted-name collisions also report `SP0068` for dropped member identities. | | Enum aliases sharing one number | The ordinal-first member is kept so scenario constants still name a declaration. Warning `SP0069` names the kept and dropped aliases. | | Parameterized `[Theory]` scenario | Information `SP0076` names the omitted theory, including theories that construct a local command, event, read-model, or reactor scenario; case tables are not generated yet. Ordinary `[Fact]` scenarios in the same class remain eligible. | | Unsupported command scenario caller | An authorized scenario is withheld with Warning `SP0039` when its caller or any referenced policy cannot be recovered exactly. An open command scenario is retained without a caller, with Information `SP0075` naming the unsupported setup. | | Unrepresentable command denial or failure | `ShouldNotBeAuthorized()` becomes `then denied` only with an emitted authorization gate, exact policies and caller, and no other outcome. Otherwise Warning `SP0039` names the missing caller, inexact policy, combined outcome, or denial not explained by any stated policy. Exception assertions and uncategorized failures remain unsupported. | | Unexpected generated-document binding errors | The document compiles but fails executable semantic binding. Warning diagnostic `SP0056` includes each binder error’s code, message, line and column; the generated text is retained for inspection. | If a generated `.play` is missing something you expected, the diagnostics are the first place to look — the omission is almost always reported. ## When this is the wrong fit If you maintain a `.play` by hand as the *design* your code is written against — modeling first, then implementing — generation is the wrong direction and will overwrite your intent. Generation suits the opposite flow: code exists, and you want the model it already describes, kept honest automatically. ## Related * [Embedded event-model explorer](/arc/backend/csharp/embedded-event-model/) — generate documents during the build and browse them inside your ASP.NET Core application. * [Vertical slices](/arc/vertical-slices/) — the folder shape the generator recovers slices from. A slice per namespace produces a far better document than artifacts sitting in the root namespace. * [Understanding the proxy boundary](/arc/understanding-the-proxy-boundary/) — the other thing Arc generates from the same source of truth. # Set up a standalone Arc backend > Create an ASP.NET Core project with Arc, choose MongoDB or SQLite through EF Core, and configure Debug proxy generation without Chronicle. Start with a backend you can understand before adding a UI. This path uses **ASP.NET Core and standalone Arc**: no Chronicle package, event store, or Cratis template is required. The [lightweight Arc.Core host](/arc/backend/csharp/core/getting-started/) is a separate hosting option; do not mix its bootstrap with this one. ## Create the project Install a .NET SDK compatible with the Arc release you select, plus Node.js for the later React step. **This tutorial targets .NET 10.** The application’s target framework and the SDK required to build Arc’s analyzers are separate choices; see the [SDK prerequisites](https://github.com/Cratis/Arc#contributing). Use matching released versions of the Arc packages; retain the resolved versions in your project and lockfiles. ```bash dotnet new web -n Library --framework net10.0 cd Library dotnet add package Cratis.Arc dotnet add package Cratis.Arc.ProxyGenerator.Build ``` Keep `Library.csproj` and `Program.cs` at the project root. Backend feature files will live under `src/Authors/`, beside the frontend files added later. This co-location is a tutorial convention, not an Arc routing requirement. Add this property group inside `Library.csproj`: ```xml $(MSBuildThisFileDirectory)src true ``` The namespace root removes `Library` from **output folders only**, placing `Library.Authors` under `src/Authors`; API routes still retain the default `/api/library/...` prefix. Source-file mode groups query proxies into their read model’s file (`Author.ts`) instead of separate query files (`AllAuthors.ts`). When following a frontend example using separate query files, import `AllAuthors` from `./Authors/Author` for this setup. The generator runs **after compilation** and uses the assembly and PDB source paths. An output path enables it; installing Arc alone does not. Use Debug builds for the following checkpoints. See [proxy configuration](/arc/backend/csharp/proxy-generation/configuration/) before changing routes or output layout. Create `GlobalUsings.cs` at the project root: ```csharp global using System.Reactive.Subjects; global using Cratis.Arc; global using Cratis.Arc.Commands; global using Cratis.Arc.Commands.ModelBound; global using Cratis.Arc.Queries; global using Cratis.Arc.Queries.ModelBound; global using Cratis.Arc.Validation; global using Cratis.Concepts; global using FluentValidation; global using Library.Authors; ``` These imports are explicit because this project does not use the `Cratis` integration meta-package’s global usings. The `Library.Authors` namespace is introduced in the next lesson; finish that lesson before building. ## Choose MongoDB Use this branch **or** the EF Core branch below, not both for the same tutorial models. ```bash dotnet add package Cratis.Arc.MongoDB ``` Add `global using MongoDB.Driver;` to `GlobalUsings.cs`. Supply these local-development settings in `appsettings.Development.json`: ```json { "Cratis": { "MongoDB": { "Server": "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true", "Database": "arc-library" } } } ``` Live queries need a replica set (or a supported sharded deployment), not a standalone `mongod`. For an isolated local Docker instance: ```bash docker run --name arc-library-mongo -d -p 127.0.0.1:27017:27017 mongo:8 --replSet rs0 --bind_ip_all docker exec arc-library-mongo mongosh --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})' docker exec arc-library-mongo mongosh --eval 'db.hello().isWritablePrimary' ``` Wait until Mongo accepts connections before running `rs.initiate`; then repeat the final check until it prints `true`. If the container exits immediately, run `docker logs arc-library-mongo`. MongoDB 8 refuses to start on Linux kernels 6.19 through 7.0.13, which includes some Docker Desktop virtual machines; the log then names the kernel incompatibility. Update Docker Desktop, or remove the container and start it from `mongo:7.0` instead, which runs this tutorial unchanged. See MongoDB’s [production notes](https://www.mongodb.com/docs/upcoming/administration/production-notes) for the affected kernels. This unauthenticated, loopback-only database is **development-only**. Use your deployment’s access controls and connection secrets in production. Arc discovers collections and its serializers; no per-collection registration is required. Replace `Program.cs`: ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(); builder.UseCratisMongoDB(); var app = builder.Build(); app.UseCratisArc(); await app.RunAsync(); ``` Database/collection creation happens on the first write. The validation chapter adds a unique index explicitly; the framework does not create that business constraint for you. ## Or choose EF Core with SQLite This local alternative needs no database container: ```bash dotnet add package Cratis.Arc.EntityFrameworkCore ``` Add these imports to `GlobalUsings.cs` instead of the MongoDB import: ```csharp global using Cratis.Arc.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore; ``` Replace `Program.cs` with this version. The next lesson supplies `LibraryDbContext`: ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(configureBuilder: arc => arc.WithEntityFrameworkCore( options => options.ConnectionString = "Data Source=library.db")); var app = builder.Build(); app.UseCratisArc(); // Tutorial bootstrap for a new, disposable database only. using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); } await app.RunAsync(); ``` The nonempty connection string enables automatic discovery of public `BaseDbContext` subclasses. Arc recognizes SQLite from `Data Source=library.db`, configures the provider and observation interceptor, and registers the context. **Do not add a redundant manual `AddDbContext` registration.** `EnsureCreatedAsync` creates an initial schema; it does **not** upgrade an existing one. Later chapters change the schema. For this disposable lesson, stop the host, remove only your tutorial `library.db`, and restart after those changes. For retained data, use [EF migrations](/arc/backend/csharp/entity-framework/migrations-add-columns/) instead; do not mix `EnsureCreated` with a migration-managed database. SQLite observation reports writes made through the configured context in this host. It does not detect arbitrary external-process writes. Other providers have additional [database notification prerequisites](/arc/backend/csharp/entity-framework/observing/). ## Next checkpoint The project is configured but its domain types are not defined yet. Continue to [Your first command and query](/arc/backend/csharp/getting-started/your-first-command/), choose the same database branch, then build and run. That page gives the observable success checkpoint before you add React. The [full-stack tutorial](/arc/tutorial/) continues from that backend. The `dotnet new cratis` template is an optional **Arc + Chronicle** scaffold, not a prerequisite for this standalone path; use [Chronicle integration](/arc/backend/csharp/chronicle/) when you intentionally choose event sourcing. # Your first command and query > Complete the standalone ASP.NET Core setup with an author command, a database-backed read model and an observable query, then verify the backend and proxies. A librarian registers an author; the catalog should show it. Let’s express that as one command that writes current state and one query that reads it. **No events are returned or appended in this lesson.** ## Prerequisites Complete [Set up a standalone Arc backend](/arc/backend/csharp/getting-started/) first. It supplies packages, imports, ASP.NET Core hosting, storage settings, and the proxy-generator configuration. Choose the **same database branch** below. Files are relative to that lesson’s `Library/` project root. ``` Handle writes Observe HTTP subscription RegisterAuthor database AllAuthors client ``` ## Define the author feature 1. **Name the domain values.** Create `src/Authors/AuthorId.cs`: ```csharp namespace Library.Authors; public record AuthorId(Guid Value) : ConceptAs(Value) { public static AuthorId New() => new(Guid.NewGuid()); } ``` Create `src/Authors/AuthorName.cs`: ```csharp namespace Library.Authors; public record AuthorName(string Value) : ConceptAs(Value) { public static implicit operator AuthorName(string value) => new(value); } ``` These are standalone `ConceptAs` values. `AuthorId` is not a Chronicle event-source identifier. 2. **Write the command.** Create `src/Authors/RegisterAuthor.cs` using one branch: * MongoDB ```csharp namespace Library.Authors; [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } ``` * EF Core ```csharp namespace Library.Authors; [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public async Task Handle(LibraryDbContext db) { db.Authors.Add(new Author(Id, Name)); await db.SaveChangesAsync(); } } ``` Arc method-injects the collection or context. The explicit write is the effect; returning `Task` adds no response payload. Separate the decision from the write These direct database calls remain supported. When the write can be described from the command’s inputs, prefer a pure `Handle()` that returns a command operation, with the collection or context injected into its `Execute()` method. Arc runs the declared work; database writes do not become automatically reversible. Follow [the migration recipe](/arc/backend/csharp/commands/operations/migrating/) when you are ready to separate the two responsibilities. 3. **Expose the read shape.** Create `src/Authors/Author.cs`: * MongoDB ```csharp namespace Library.Authors; [ReadModel] public record Author(AuthorId Id, AuthorName Name) { public static ISubject> AllAuthors(IMongoCollection authors) => authors.Observe(); } ``` * EF Core ```csharp namespace Library.Authors; [ReadModel] public record Author(AuthorId Id, AuthorName Name) { public static ISubject> AllAuthors(LibraryDbContext db) => db.Authors.Observe(); } ``` Also create `src/Authors/LibraryDbContext.cs`: ```csharp namespace Library.Authors; public class LibraryDbContext(DbContextOptions options) : BaseDbContext(options) { public DbSet Authors => Set(); } ``` A static query on `[ReadModel]` becomes an endpoint. MongoDB observation uses change streams; SQLite uses this host’s configured `SaveChanges` notifications. The host setup establishes their prerequisites. ## Build and run the checkpoint ```bash dotnet build -c Debug dotnet run --no-build --no-launch-profile \ -e ASPNETCORE_ENVIRONMENT=Development --urls http://localhost:5000 ``` After the build, check for `src/Authors/RegisterAuthor.ts` and `src/Authors/Author.ts`. With source-file output enabled, the latter contains the `AllAuthors` query as well as the author shape. Do not edit either generated file. The console should print `Now listening on: http://localhost:5000` and `Hosting environment: Development`. EF creates the initial SQLite schema at startup; MongoDB should already report a writable primary. If either fails, fix storage before moving to the browser. Set the environment through ASPNETCORE_ENVIRONMENT `--no-launch-profile` skips the Development environment that `launchSettings.json` would set, so the command sets `ASPNETCORE_ENVIRONMENT` itself. In the .NET 10 SDK, `dotnet run -e` (long form `--environment`) sets an environment *variable* as `NAME=VALUE`; `--environment Development` does not select an ASP.NET Core environment. If the log says `Hosting environment: Production`, `appsettings.Development.json` was not loaded: the MongoDB branch then stops at startup because `Server` and `Database` are required, and the authorization chapter’s Development-only sign-in is never registered. Build success is not a write test This checkpoint establishes compilation, proxy output, and host/storage startup. The next lesson executes a command and verifies persisted data through the query. It does not assume that a green build proves the write worked. To exercise the backend before any UI exists, call the routes the generated proxies use. From a second terminal: ```bash curl -s -X POST http://localhost:5000/api/library/authors/register-author \ -H 'Content-Type: application/json' \ -d '{"id":"8f7c7c52-2d5d-4c1e-9a55-0c1f2b8a6f10","name":"Ursula K. Le Guin"}' curl -s http://localhost:5000/api/library/authors/all-authors ``` The command returns `"isSuccess":true`, and the query’s `data` array contains the author. Run the command once: the id is the stored key, so repeating it reports a storage error rather than a second author. The routes come from the namespace (`Library.Authors`) plus the command or query name, and the generated `route` fields in `RegisterAuthor.ts` and `Author.ts` show the same paths. Nothing validates the name yet, so a blank name is also accepted until [chapter 2](/arc/tutorial/validation/) adds the rules. ## Continue in the browser [Your first full-stack slice](/arc/tutorial/first-slice/) explains these same backend files and supplies the Vite bootstrap, Components prerequisites, providers, mounting, and dev proxy configuration; do not create a second copy of the backend types. [Frontend getting started](/arc/frontend/react/getting-started/) is the **alternative for an existing Vite app**: an inline `CommandForm` with Components fields, plus a route without Components. With this tutorial’s source-file grouping, use `./Authors/Author` for its `AllAuthors` import. Go deeper in [Commands](/arc/backend/csharp/commands/) and [Queries](/arc/backend/csharp/queries/). If you later choose event sourcing, [add Chronicle explicitly](/arc/backend/csharp/chronicle/add-event-sourcing/); retaining a query’s public contract is a migration goal, not a guarantee that its storage implementation never changes. # Identity > How Arc composes application identity details from an authenticated principal for the frontend, and how that differs from authorization. Arc identity support composes domain-specific details from an authenticated request principal and presents them to frontend clients. It does not modify or validate the provider’s token. Authentication, pipeline authorization, and identity presentation are separate concerns. ## Overview Provider identities often lack application-specific display information. Arc lets you compose it in the backend and publish a consistent frontend payload. A details provider can make an application-entry decision; it runs for every identity request. Protect commands and queries independently with [pipeline authorization](/arc/backend/csharp/core/authorization/). Key capabilities: * Enrich provider identity with domain-specific details * Supply an application-entry decision on every identity request * Return a single consolidated identity payload * Present details to frontend clients through `/.cratis/me` ``` No Yes Authenticated Request Principal Fresh Identity Enrichment Authorized? HTTP 403 Identity Details JSON /.cratis/me response Frontend Identity Consumption ``` Caution The identity is presentation data. Do not use its flags, roles, or details in place of backend authorization. See [provider flow](/arc/backend/csharp/identity/provider-flow/) and [trust](/arc/backend/csharp/identity/identity-provider-service/#trust). Arc no longer uses the `.cratis-identity` cookie; see [migrating from the identity cookie](/arc/backend/csharp/identity/migrating-from-the-identity-cookie/). ## Topics | Topic | Description | | --- | --- | | [Provider Flow](/arc/backend/csharp/identity/provider-flow/) | Endpoint mapping, provider implementation, request flow, and frontend integration. | | [Identity Contracts](/arc/backend/csharp/identity/contracts/) | `IdentityProviderContext` and `IdentityDetails` structures used by providers. | | [IdentityProvider Service](/arc/backend/csharp/identity/identity-provider-service/) | Advanced runtime identity retrieval and mutation with `IIdentityProvider`. | | [Development and Topologies](/arc/backend/csharp/identity/development-and-topologies/) | Development endpoints plus single-service and multi-service composition patterns. | | [Migrating from the Identity Cookie](/arc/backend/csharp/identity/migrating-from-the-identity-cookie/) | What changed when Arc stopped trusting the `.cratis-identity` cookie, and what to change. | # Identity Contracts > Reference for IdentityProviderContext and IdentityDetails, the input and output of an identity details provider. Identity providers work with two key contracts: `IdentityProviderContext` as input and `IdentityDetails` as output. ## IdentityProviderContext `IdentityProviderContext` contains the incoming identity data. | Property | Description | | --- | --- | | Id | The identity identifier from the identity provider | | Name | The display name of the identity | | Claims | Collection of `KeyValuePair` claims from the authenticated request principal, not a token parsed by this provider | ## IdentityDetails `IdentityDetails` represents the provider result. | Property | Description | | --- | --- | | IsUserAuthorized | Whether the user is authorized to enter the application | | Details | Domain-specific details as an object payload | On `/.cratis/me`, a result marked unauthenticated yields HTTP 401; one marked authenticated but unauthorized yields 403; an authenticated and authorized result yields 200. These flags come from the authenticated principal and a fresh provider invocation. `IsUserAuthorized` does not add claims or authorize commands/queries; see [provider flow](/arc/backend/csharp/identity/provider-flow/). > Note: Providers can use constructor dependencies via dependency inversion. # Development and Topologies > Expose development users and tenants for tools such as Lens, and run identity across ingress and microservice topologies. ## Development Users and Tenants For development scenarios, Arc automatically exposes HTTP endpoints that development tools use to discover available users and tenants. This eliminates the need to hard-code user lists in your frontend or development environment — just implement the providers and Arc surfaces them. The primary consumer of these endpoints is [Lens](/tools/lens/), the Cratis browser extension for exercising a running Arc application during development. Lens reads `/.cratis/tenants` and `/.cratis/users` to populate its tenant and user pickers, then injects the corresponding identity and tenant headers into every request your frontend makes while Lens is active — see [Lens: where the tenant and user roster comes from](/tools/lens/#where-the-tenant-and-user-roster-comes-from) for the extension side of this contract, with screenshots against seeded demo data. This is also the [Tenancy](/arc/backend/csharp/tenancy/) discovery seam’s development counterpart: it answers “which tenants exist”, not “which tenant is this request for”. ### Available Endpoints Normal Arc activation maps these endpoints unless `Cratis:Arc:Introspection:IdentityDiscovery` is `false` or replacements with the same endpoint names already exist, even when no development providers are registered: * `/.cratis/users` — Returns all available development users * `/.cratis/tenants` — Returns all available development tenants Setting `IdentityDiscovery` to `false` also removes the identity-details schema, without changing catalogs or `/.cratis/me`. To remove all discovery endpoints, [turn off both discovery switches](/arc/backend/csharp/introspection/#turn-off-all-discovery-in-deployed-environments). Without providers they return empty arrays. With providers they combine their results. They follow the [discovery access settings](/arc/backend/csharp/introspection/#production-access): anonymous in Development, and requiring an authenticated caller everywhere else unless `Cratis:Arc:Introspection:RequireAuthentication` is `false`. Exclude development-only provider implementations from production discovery anyway; an authenticated caller should not see a fixture user list either. A user/tenant list is not proof of authentication or membership. ### Implementing a Users Provider Create a class implementing `ICanProvideUsers`. Arc will discover it automatically. ```csharp using Cratis.Arc.Identity; public class DevelopmentUsersProvider : ICanProvideUsers { public Task> Provide() { var users = new List { new User( new ClientPrincipal { UserId = "alice@contoso.com", UserDetails = "Alice Developer", IdentityProvider = "aad", UserRoles = new[] { "admin", "developer" } }, Details: new { Department = "Engineering" } ), new User( new ClientPrincipal { UserId = "bob@contoso.com", UserDetails = "Bob Tester", IdentityProvider = "aad", UserRoles = new[] { "tester" } }, Details: new { Department = "QA" } ) }; return Task.FromResult>(users); } } ``` ### Implementing a Tenants Provider `ICanProvideTenants` is a development-only seam: it has nothing to do with tenant *resolution* at request time (see [Tenancy](/arc/backend/csharp/tenancy/)). It exists purely so a tool like Lens can ask your running application, at any moment, “which tenants do you know about right now?” — sourced from wherever your application already keeps that list (a fixed set, a database, a configuration section), rather than a hand-maintained copy pasted into the tool. Similarly, create a class implementing `ICanProvideTenants`: ```csharp using Cratis.Arc.Tenancy; public class DevelopmentTenantsProvider : ICanProvideTenants { public Task> Provide() { var tenants = new List { new Tenant( Id: new TenantId("acme-corp"), Name: new TenantName("ACME Corporation") ), new Tenant( Id: new TenantId("widget-inc"), Name: new TenantName("Widget Inc") ) }; return Task.FromResult>(tenants); } } ``` ### How Development Tooling Uses These Endpoints Development tools — [Lens](/tools/lens/) chief among them, plus the Cratis Portal or custom dev dashboards — use these endpoints to populate dropdown menus and user/tenant selectors. Instead of hard-coding a list of test users or maintaining them in configuration, your code is the source of truth: * The tool fetches `/.cratis/users` to populate user-selection dropdowns * The tool fetches `/.cratis/tenants` to populate tenant-selection dropdowns * Developers can switch context without rebuilding With Lens specifically: once a tenant or user is selected in the extension popup, every subsequent request from the page under inspection carries the matching tenant header and identity headers, so your backend’s [tenant resolution](/arc/backend/csharp/tenancy/resolvers/) and [identity provider](/arc/backend/csharp/identity/provider-flow/) see the switch exactly as they would from a real caller — no restart, no manually crafted headers. You can have **multiple providers** — all registered providers are discovered and their results merged. This is useful when users or tenants come from different sources (database, configuration, external service). ### Multiple Providers You can implement multiple providers of the same interface. All will be discovered and their results combined. This is useful when users or tenants come from different sources (configuration, database, external service) — each source gets its own provider, and Arc merges them automatically. ## Service Topologies In a microservices architecture, you have several implementation options: 1. **Single service** — Implement `IProvideIdentityDetails` in the main service 2. **Multiple services** — Let ingress or reverse proxy call multiple services and merge the results 3. **Dedicated identity service** — Aggregate identity data in a specialized service Choose the topology that best fits your architecture and operational model. Cross-service aggregation is application-owned; do not forward identity details as authorization evidence. Preserve trusted authentication and enforce permissions at each service boundary. # IdentityProvider service > Read identity presentation data with IIdentityProvider, which always derives the identity from the authenticated request. `IIdentityProvider` serves identity presentation data during HTTP requests. It always derives the identity from the authenticated principal of the current request and the details your `IProvideIdentityDetails` returns for it. Nothing else the client sends, such as the `.cratis-identity` cookie earlier versions wrote, is read. The result is still presentation data: **it is not authorization evidence on its own**. ## Key methods | Method | Behavior | | --- | --- | | `Get()` | Builds a result from the current request principal and details provider. With no request context, or no authenticated principal, returns anonymous. | | `Get()` | Uses the same flow and converts the details to `TDetails`. | | `SetCookieForHttpResponse(result)` | Writes the result as the JSON response body, with `Cache-Control: no-store, private` and `Vary: Cookie`. Despite its name, it no longer writes a cookie; it expires a `.cratis-identity` cookie left by an earlier version. It does not establish a trusted principal. | | `ModifyDetails(transform)` | Calls nongeneric `Get()`, applies the transform when `Details is TDetails`, and writes the modified result to the response; otherwise does nothing. | ## Trust The identity comes from the authenticated principal, so a client cannot change what `Get()` returns without changing its credentials. The details provider runs on every call; cache expensive lookups inside it if you need to. Never use the result’s `IsAuthenticated`, `IsAuthorized`, roles, or details in place of authorization. Read `ICurrentPrincipalAccessor.Current` from `Cratis.Arc.Authorization` and authoritative application data, and protect commands and queries with [pipeline authorization](/arc/backend/csharp/core/authorization/). The result is sent to the browser, so avoid putting secrets or unnecessary personal data in the details. ## Modifying details `ModifyDetails(...)` changes what the current response reports. It does not store anything: the next request derives the identity from the principal again. Persist durable preferences through an authenticated application operation and authoritative storage, and return them from your details provider. `ModifyDetails` writes the JSON response itself; do not write another response body after calling it. ## Upgrading from the identity cookie Earlier versions returned whatever the unsigned `.cratis-identity` cookie decoded to before looking at the authenticated principal, and wrote that cookie readable by any script. Arc no longer trusts or writes it. See [migrating from the identity cookie](/arc/backend/csharp/identity/migrating-from-the-identity-cookie/) for what keeps working during the transition and what to change. ## Related contracts * [Migrating from the identity cookie](/arc/backend/csharp/identity/migrating-from-the-identity-cookie/) * [Provider flow](/arc/backend/csharp/identity/provider-flow/) — how a request becomes an identity. * [Authorization](/arc/backend/csharp/core/authorization/) — trusted principal and pipeline enforcement. * [Frontend identity](/arc/frontend/react/identity/) — presenting identity details. # Migrating from the identity cookie > What changed when Arc stopped trusting and writing the .cratis-identity cookie, what keeps working during the transition, and what applications must change. Earlier versions of Arc wrote the identity to a `.cratis-identity` cookie: unsigned base64 JSON with `HttpOnly = false`. On the server, `IIdentityProvider.Get()`, `Get()` and `/.cratis/me` returned whatever that cookie decoded to before looking at the authenticated principal. In the browser, `IdentityProvider.getCurrent()` read it before asking the server. Anyone who could set the cookie, such as a browser user or a script on a sibling subdomain, therefore decided which identity the application reported. Any script on the page could also read the identity, roles and details from it. Arc now derives the identity only from the authenticated request, and the frontend gets it from `/.cratis/me`. ## What changed | | Before | Now | | --- | --- | --- | | `Get()`, `Get()`, `/.cratis/me` | Returned the cookie when one was sent, otherwise the principal | Always the authenticated principal; a sent cookie is ignored. Without a principal, `/.cratis/me` answers 401. | | `/.cratis/me` response | JSON body plus a readable `.cratis-identity` cookie | JSON body only. A `.cratis-identity` cookie on the request is expired. | | `IProvideIdentityDetails` | Skipped while the browser held the cookie | Runs on every `/.cratis/me` call | | `ModifyDetails` | Wrote the modified identity to the cookie | Changes the current response only | | `IdentityProvider.getCurrent()` in `@cratis/arc` | Read the cookie, and called `/.cratis/me` only without one | Calls `/.cratis/me` once and keeps the answer in memory. Reads the cookie only when `/.cratis/me` answers 404, and warns when it does. | | `IdentityProvider.clearIdentityCookie()` | Expired the cookie | Deprecated; same as the new `clearCache()` | ## What keeps working during the transition The NuGet and npm packages do not have to be upgraded together: * **New backend, earlier frontend.** The earlier frontend reads the cookie first and calls `/.cratis/me` when there is none, which is now always the case for new sessions because the new backend does not write it. A cookie left from before the upgrade lasts until the browser session ends or the frontend next calls `/.cratis/me`, for example through `refresh()`. That response expires it. * **Earlier backend, new frontend.** The new frontend asks `/.cratis/me`, which the earlier backend serves as before. An explicit `refresh()` expires the readable legacy cookie before requesting the identity, so the earlier backend recomputes it from the current credentials rather than returning stale identity, roles or details. * **Identity from a proxy in front of the application.** Cratis AuthProxy writes its own readable `.cratis-identity` cookie. When the browser’s `/.cratis/me` request reaches the application, the identity comes from the forwarded principal. When nothing answers `/.cratis/me` (404), the new frontend falls back to the proxy’s cookie and logs a warning in the browser console. After an explicit refresh answers 404, it restores the fallback cookie at the root path so the identity survives a page reload even if the proxy does not reissue it. `clearCache()` expires that cookie on logout. That fallback exists for the transition and will be removed in a future major version. * A 401 or 403 from `/.cratis/me` is never overruled by a cookie. The identity is reported as not set. ## What applications must change 1. **Expose `/.cratis/me` to the frontend.** If the browser console shows the warning about reading the identity from the `.cratis-identity` cookie, the frontend cannot reach `/.cratis/me`. Arc maps this endpoint by default, with `DefaultIdentityDetailsProvider` when you have no custom provider. Check proxy routing and the frontend’s `apiBasePath` and origin so the request reaches your Arc application, and make sure the proxy forwards the authenticated principal. Register a custom `IProvideIdentityDetails` only when you need application-specific details. 2. **Stop reading the cookie yourself.** Code that reads `.cratis-identity` from `document.cookie`, or from the request on the server, must use `IdentityProvider.getCurrent()` or `useIdentity()` in the browser, and `IIdentityProvider.Get()` or `ICurrentPrincipalAccessor` on the server. 3. **Replace `IdentityProvider.clearIdentityCookie()` with `IdentityProvider.clearCache()`.** 4. **Replace `IdentityProvider.IdentityCookieName`** (.NET) and `IdentityProvider.CookieName` (TypeScript). Both are obsolete. A build that treats warnings as errors fails on the .NET constant until the reference is removed. 5. **Make the details provider cheap enough to run on every `/.cratis/me` call.** Cache expensive lookups inside it if needed. 6. **Proxies that write `.cratis-identity` should stop.** Nothing in Arc trusts it, and once the frontend reaches `/.cratis/me`, nothing reads it. ## Changes that cannot be made compatible * **Reading the cookie directly.** Code that read `.cratis-identity` itself, rather than through Arc, gets nothing once the backend is upgraded, because the backend no longer writes the cookie. Writing it again would bring back the exposure this change removes. Move that code to `/.cratis/me`. * **`ModifyDetails` across requests.** The modified details used to be stored in the client-controlled cookie and returned on the next request. Arc no longer trusts that cookie, so the change cannot be carried to the next request. Store such preferences through an authenticated operation and return them from your details provider. * **Identity before the first request.** The cookie let the first render show the identity without a round trip. The identity now arrives with the `/.cratis/me` response. Render a loading state until it does. In React, `useIdentity()` reports `isLoading`. ## Related * [IdentityProvider service](/arc/backend/csharp/identity/identity-provider-service/) * [Provider flow](/arc/backend/csharp/identity/provider-flow/) * [Frontend identity](/arc/frontend/core/identity/) # Provider flow > Understand how Arc turns the authenticated principal of a request into the identity the frontend sees. Authentication establishes a principal; identity enrichment supplies application-specific information for the frontend. Keeping those steps separate prevents a UI convenience from becoming an accidental permission boundary. ## Endpoint mapping Normal `UseCratisArc()` activation maps `/.cratis/me` when `IProvideIdentityDetails` is registered, unless a replacement endpoint with the same endpoint name already exists. You do not need a second mapping call when using the standard host setup. The endpoint has anonymous metadata so it can handle identity results itself. It calls `IIdentityProvider.Get()`, returns 401 for a result marked unauthenticated, 403 for a result marked unauthorized, and otherwise writes the identity as JSON. The result is always derived from the authenticated principal of the request; Arc does not read or write an identity cookie. Arc sets `Cache-Control: no-store, private` and `Vary: Cookie` on `/.cratis/me` (including its 401 and 403 responses), `/.cratis/users`, and `/.cratis/tenants`. It also sets these headers whenever `IIdentityProvider.SetCookieForHttpResponse()` writes an identity, including after `ModifyDetails()`. Do not configure a shared cache to override these headers: the responses contain per-user data. The application-wide `/.cratis/identity-details/schema` response is not covered by this identity-response policy. ## Identity details provider Arc discovers `IProvideIdentityDetails` implementations. On every identity request, it checks the request principal, constructs `IdentityProviderContext`, and invokes the provider. The ID comes from the principal’s `sub` claim (or `"unknown"` when absent); the name comes from the principal, with the forwarded name header as a fallback. This **illustrative type fragment** supplies display data only, allowing every already-authenticated user through this enrichment step. It is not an application membership policy: ```csharp using Cratis.Arc.Identity; public class IdentityDetailsProvider : IProvideIdentityDetails { public Task Provide(IdentityProviderContext context) => Task.FromResult(new IdentityDetails(true, new { DisplayName = context.Name })); } ``` For an application-entry decision, your provider must consult authoritative membership data. That decision still does not protect every command/query: enforce those permissions in their pipelines. ## Request flow and frontend integration ``` No Yes Request Authenticated request principal? HTTP 401 Compose details using provider Identity response Frontend presentation ``` The frontend gets the identity by calling `/.cratis/me` and keeps the answer in memory for the page. The identity does not add trusted claims or authorize pipeline execution. Read [trust and mutation limits](/arc/backend/csharp/identity/identity-provider-service/) before using `IIdentityProvider` in backend code. For client consumption, see [React identity integration](/arc/frontend/react/identity/). # Introspection > Inspect your application's commands, queries, and identity schema at runtime, and decide how to expose those endpoints in production. Arc exposes introspection endpoints that let you inspect the command and query surface of your application at runtime. ## Where to find the endpoints Normal `UseCratisArc()` activation maps these endpoints unless replacements with the same endpoint names already exist. * `/.cratis/commands` * `/.cratis/queries` * `/.cratis/identity-details/schema` (also mapped without a details provider; then returns `{}`) Together with [user and tenant discovery](/arc/backend/csharp/identity/development-and-topologies/) (`/.cratis/users` and `/.cratis/tenants`) these are Arc’s **discovery endpoints**, the routes local tooling such as Lens and the Cratis CLI reads. The command and query catalog endpoints are mapped in both Arc.Core and ASP.NET Core hosting scenarios through `MapIntrospectionEndpoints()`. Normal Arc activation calls it automatically. The identity-details schema, users and tenants are mapped by the identity endpoint mapper. `Enabled` controls only the catalogs; `IdentityDiscovery` controls the other three discovery routes. Both default to `true`. The access settings below apply to mapped discovery endpoints. ## Production access The discovery endpoints expose operation names, types, routes, users, tenants and the identity details schema, regardless of whether a caller may execute anything. By default Arc therefore exposes them anonymously only in Development, so local tooling works without configuration, and requires an authenticated caller everywhere else. Development follows the host’s `IHostEnvironment.IsDevelopment()`, including environment names selected through configuration, command-line arguments or host options. Only without a registered host environment does discovery fall back to `ASPNETCORE_ENVIRONMENT`. Outside Development, if the host has no way to authenticate callers, for example an ASP.NET Core host without a default authentication scheme, Arc does not map the discovery endpoints at all and logs a warning that names the setting below. `/.cratis/me` is not a discovery endpoint: it answers for the caller itself and is always mapped. Configure `Cratis:Arc:Introspection` to change that boundary: ```json { "Cratis": { "Arc": { "Introspection": { "Enabled": true, "RequireAuthentication": true, "Roles": "Administrator,Operator" } } } } ``` | Option | Default | Effect | | --- | --- | --- | | `Enabled` | `true` | `false` leaves both catalog routes unmapped; identity discovery is unchanged. | | `IdentityDiscovery` | `true` | `false` leaves users, tenants and the identity-details schema unmapped; catalogs and `/.cratis/me` are unchanged. | | `RequireAuthentication` | not set | Not set: anonymous in Development, authenticated elsewhere. `true` requires an authenticated caller for enabled discovery routes in every environment, regardless of the host’s default authorization policy, and fails startup if the host cannot authenticate callers. `false` exposes enabled discovery routes anonymously in every environment, and logs a warning on startup outside Development. | | `Roles` | `null` | Comma-separated roles; any one grants access. Requires an authenticated caller in every environment, cannot be combined with `RequireAuthentication: false`, and rejects empty entries. A caller without a listed role is denied. | | `TrustForwardedIdentityHeaders` | `false` | Obsolete. Setting it to `true` turns on the host-wide `Cratis:Arc:TrustForwardedIdentityHeaders`. See [Forwarded identity headers](#forwarded-identity-headers). | Startup validation rejects `Roles` combinations that do not meet these requirements, even when discovery is disabled. Valid `Roles` and `RequireAuthentication` settings can remain configured: they have no effect on unmapped routes. Arc.Core responds with 401 for anonymous requests and 403 for authenticated callers without a required role. On ASP.NET Core, the response depends on the configured authentication scheme’s challenge and forbid behavior: cookie authentication can redirect instead of returning 401 or 403 unless configured otherwise. To omit an individual .NET command or query from the catalogs and generated OpenAPI, apply `[ExcludeFromDiscovery]` to the command, read model, query method, or controller action. This leaves invocation unchanged and is **not** an authorization mechanism. The catalog options and their defaults are unchanged. The JVM backend has no equivalent options: its catalog endpoints are always anonymous. See [the HTTP contract](/arc/http-contract/#authentication-and-introspection). ### Forwarded identity headers The Microsoft Identity Platform (EasyAuth) mechanism reads `x-ms-client-principal*` headers without verifying a signature. Anyone who can reach the backend directly can forge them. Arc therefore ignores them on every request, including the protected catalogs, until the host sets `Cratis:Arc:TrustForwardedIdentityHeaders` to `true`. Without the opt-in, a request carrying only these headers is anonymous and a protected catalog returns 401. **Only opt in behind a trusted ingress that authenticates callers, strips client-supplied identity headers, writes its own values, and prevents direct access to the backend.** ```json { "Cratis": { "Arc": { "TrustForwardedIdentityHeaders": true, "Introspection": { "RequireAuthentication": true } } } } ``` The opt-in is host-wide: once the host trusts the headers, they authenticate the catalogs exactly as they authenticate commands and queries. See [Microsoft Identity](/arc/backend/csharp/asp-net-core/microsoft-identity/#trusting-the-forwarded-headers). ### ASP.NET Core host When discovery is enabled and `RequireAuthentication: true`, startup requires a default authentication scheme and `builder.Services.AddAuthorization()`. The catalog combines ASP.NET Core’s default authorization policy with an explicit authenticated-user requirement and any configured roles. The built-in `MicrosoftIdentityPlatform` handler checks the host-wide opt-in inside `HandleAuthenticateAsync`, so the check also covers route groups, named policies, policy schemes that forward to the handler, and authentication that runs before routing. A subclass that overrides `HandleAuthenticateAsync` without calling the base implementation is your own authentication handler: Arc cannot see what it reads, so make it honor `ArcOptions.TrustForwardedIdentityHeaders` too. ### Arc.Core HttpListener host When discovery is enabled and `RequireAuthentication: true`, startup requires at least one Arc.Core authentication handler. The built-in `MicrosoftIdentityPlatformAuthenticationHandler` ignores the forwarded headers until the host opts in, so a request that carries only those headers returns 401. Arc cannot tell whether your own `IAuthenticationHandler` reads forwarded headers. If your handler establishes identity from headers that an ingress sets, make it honor the same opt-in: when `TrustForwardedIdentityHeaders` is `false`, return `AuthenticationResult.Anonymous`. See [Authentication](/arc/backend/csharp/core/authentication/#microsoft-identity-platform-azure). These options do **not** change command/query invocation or `/.cratis/me`. Test anonymous and authorized requests against your deployed Production configuration. To keep the discovery endpoints anonymous outside Development, for example for tooling that reads a deployed host without signing in, set: ```json { "Cratis": { "Arc": { "Introspection": { "RequireAuthentication": false } } } } ``` ## Turn off all discovery in deployed environments If you do not want deployed hosts to expose catalogs, user or tenant lists, or the identity schema, set **both** switches to `false` in `appsettings.Production.json` (or the settings file for your deployed environment): ```json { "Cratis": { "Arc": { "Introspection": { "Enabled": false, "IdentityDiscovery": false } } } } ``` Alternatively, supply these environment variables: ```bash export Cratis__Arc__Introspection__Enabled=false export Cratis__Arc__Introspection__IdentityDiscovery=false ``` Arc maps none of its five discovery routes, skips discovery authentication enforcement checks, and logs no discovery exposure warning. `/.cratis/me` and command/query invocation remain unchanged. Local tooling cannot read the disabled discovery routes; keep the defaults in Development when you need those tools. ## What introspection does Introspection returns metadata, not business data. It helps you: * Discover command handlers and query performers. * Inspect explicit `[Path]` routes or convention-derived routes and operation names. * Read operation summaries populated from type metadata. * Build tooling, diagnostics, and client-side discovery workflows. This is discovered-operation metadata, not the final mapped/deduplicated runtime route table. In particular, query introspection does not apply custom `[Path]` routes; see the [query metadata reference](/arc/backend/csharp/introspection/queries/) before using `route` as a callable URL. ## Cross-implementation shape Introspection is part of Arc’s [shared HTTP contract](/arc/http-contract/), which both the C# and JVM backends honour. The endpoint paths are common; the C# host can configure catalog access as described above. The *depth* of the reported metadata is also different. See [Introspection metadata depth](/arc/http-contract/#introspection-metadata-depth) before building tooling that has to run against both backends. ## Topics | Topic | Description | | --- | --- | | [Commands](/arc/backend/csharp/introspection/commands/) | Metadata shape and behavior for `/.cratis/commands`. | | [Queries](/arc/backend/csharp/introspection/queries/) | Metadata shape and behavior for `/.cratis/queries`. | | [Identity details schema](/arc/backend/csharp/introspection/identity-details-schema/) | JSON Schema shape for `/.cratis/identity-details/schema`. | # Introspection Commands Endpoint > Reference for GET /.cratis/commands, which returns metadata and payload schemas for discovered command handlers. The commands introspection endpoint returns metadata for discovered command handlers, not the final runtime route table. ## Endpoint `GET /.cratis/commands` Normal Arc activation maps this endpoint anonymously in Development and requires authentication elsewhere by default. A host that cannot authenticate leaves it unmapped and logs a warning. Returned metadata is not filtered by the caller’s command permissions. See [production access controls](/arc/backend/csharp/introspection/#production-access). ## What it returns The endpoint returns a JSON array where each item describes one discovered command handler. Each item includes: * `name`: Command type name. * `namespace`: Namespace derived from the handler’s location after skipping the default namespace segments. * `route`: Explicit `[Path]` if the command declares one; otherwise the convention-derived command route using the default prefix and namespace segments (see the note below). * `type`: Fully qualified command type name. * `documentationSummary`: Summary text from type metadata when available. * `payloadSchema`: JSON Schema describing the command payload contract (fields/properties and types). Introspection does not mirror all final endpoint replacement decisions. It also derives `namespace` and `route` from the **default** route options, not from `Cratis:Arc:GeneratedApis` (`ArcOptions.GeneratedApis`), which is what the endpoint mapper uses. If you change the route prefix, segments to skip, or name inclusion there, the reported routes can differ from the callable ones. Use it to inspect discovered-operation metadata, not as an authoritative inventory of callable URLs. ## Typical uses * Generate dynamic command catalogs in internal tooling. * Verify command route conventions in development environments. * Support diagnostics for endpoint mapping and registration. # Introspection Identity Details Schema Endpoint > Reference for GET /.cratis/identity-details/schema, which returns the JSON Schema of your identity details contract. The identity details schema introspection endpoint returns JSON Schema for the identity details contract exposed by your configured identity details provider. ## Endpoint `GET /.cratis/identity-details/schema` ## When Arc maps this endpoint Normal Arc identity activation maps this endpoint unless `Introspection.IdentityDiscovery` is `false`, one with the same endpoint name already exists, or the host cannot enforce its discovery access settings. Like the other four discovery endpoints, it follows `Introspection.RequireAuthentication` and `Introspection.Roles`: anonymous in Development and authenticated elsewhere by default. Outside Development, a host without authentication leaves it unmapped and logs a warning; explicitly requiring authentication on such a host fails startup. Setting `Introspection.Enabled` to `false` disables only the command and query catalogs, not this endpoint. Set both `Enabled` and `IdentityDiscovery` to `false` to remove every discovery endpoint without authentication enforcement checks or exposure warnings; `/.cratis/me` is unchanged. See [production access](/arc/backend/csharp/introspection/#production-access). ## What it returns The endpoint returns a JSON Schema document generated from the runtime identity details type: * If your provider implements `IProvideIdentityDetails`, Arc generates schema for `TDetails`. * If your provider only implements non-generic `IProvideIdentityDetails`, Arc generates schema for `object`. * If no identity details provider is configured, Arc returns an empty schema object (`{}`). ## Typical uses * Discover the identity details shape at runtime without hardcoding provider types. * Drive client-side tooling that validates or renders identity details data. * Keep integration tests and diagnostics aligned with the active identity provider contract. # Introspection Queries Endpoint > Reference for GET /.cratis/queries, which returns metadata and argument schemas for discovered query performers. The queries introspection endpoint returns metadata for discovered query performers, not the final runtime route table. ## Endpoint `GET /.cratis/queries` Normal Arc activation maps this endpoint anonymously in Development and requires authentication elsewhere by default. A host that cannot authenticate leaves it unmapped and logs a warning. Returned metadata is not filtered by the caller’s query permissions. See [production access controls](/arc/backend/csharp/introspection/#production-access). ## What it returns The endpoint returns a JSON array where each item describes one discovered query performer. Each item includes: * `name`: Query name. * `namespace`: Namespace derived from the performer’s location after skipping the default namespace segments. * `route`: Explicit `[Path]` if present; otherwise the convention-derived query route using the **default** route options, not `Cratis:Arc:GeneratedApis` (`ArcOptions.GeneratedApis`). * `fullyQualifiedName`: The performer’s fully qualified query name. * `type`: Fully qualified query type name. * `documentationSummary`: Summary text from type metadata when available. * `argumentsSchema`: JSON Schema for query arguments, including argument names, types, and required fields. Introspection applies a performer’s custom `[Path]`, but does not mirror all final route deduplication/replacement decisions. Treat it as discovered-operation metadata, not an authoritative inventory of callable URLs. Queries marked `[ExcludeFromDiscovery]` on the read model or method are omitted without disabling their routes. ## Typical uses * Build query metadata explorers for developers. * Inspect conventional query route generation across features. * Feed endpoint metadata into local API diagnostics tools. # MongoDB > Optional document persistence, BSON conventions, and observation for standalone Arc. Use MongoDB when your Arc application needs document storage. `Cratis.Arc.MongoDB` handles collection injection and BSON conventions while letting you use the MongoDB driver directly. It is optional and does not require Chronicle. ## Start with a read Follow [getting started](/arc/backend/csharp/mongodb/getting-started/) to install the package, configure a server/database, and activate your host. Keep that page as the canonical setup path rather than copying an incomplete bootstrap. ## Model your documents * [Concepts](/arc/backend/csharp/mongodb/concepts/): store strongly typed values without wrapper documents. * [Serializers](/arc/backend/csharp/mongodb/serializers/): understand BSON representations, including date precision and Guid formats. * [Naming policies](/arc/backend/csharp/mongodb/naming-policies/): choose collection and member names before writing data. * [Class mapping](/arc/backend/csharp/mongodb/class-mapping/): customize individual document types. * [Convention packs](/arc/backend/csharp/mongodb/convention-packs/): apply and filter shared conventions. * [Geospatial types](/arc/backend/csharp/mongodb/geospatial/): store GeoJSON and construct driver query geometry. ## Add live reads and tenancy * [Observe collections](/arc/backend/csharp/mongodb/observing-collections/): observations over the shared change stream, lifetime, and errors. * [Watch multiple collections](/arc/backend/csharp/mongodb/change-stream-watcher/): shared database change streams and joined results. * [Tenancy](/arc/backend/csharp/mongodb/tenancy/): database naming and scope boundaries. MongoDB observation is not an event log, and MongoDB persistence does not append Chronicle events. See the separate [Chronicle integration](/arc/backend/csharp/chronicle/) if you intentionally add event sourcing. # Watch multiple collections with a shared change stream > Join live results through Arc's per-database MongoDB watcher. The `IMongoDBWatcher` provides a single, process-wide MongoDB change stream connection per database. Instead of opening one change stream per collection, every observation shares one connection and funnels events from multiple collections through it. This reduces the number of open cursors and network connections, which matters when you observe many collections simultaneously. The `Observe()`, `ObserveSingle()` and `ObserveById()` [collection extensions](/arc/backend/csharp/mongodb/observing-collections/) use this shared stream too, so you do not have to inject the watcher to benefit from it. Inject it when you want to join several collections into one result. ## How it works ``` one change stream Join + Select Join + Select MongoDB Database IMongoDBWatcher ObserveBuilder ObserveBuilder ISubject ISubject ``` `IMongoDBWatcher` is registered as a singleton automatically when you call `UseCratisMongoDB()`. It keeps one change stream per database per deployment: the key is the servers and credential of the client together with the database name, the same way Arc’s client factory shares clients. The stream opens on the first observation of that database and later observations share it. * `Observe()` on the watcher resolves the database name and client from the current context, so tenants that resolve to different databases or servers get streams of their own. * The collection extensions (`Observe()`, `ObserveSingle()`, `ObserveById()`) watch the collection’s own database with the client it belongs to. A collection from a client the application did not configure, such as another library’s storage, is watched on the deployment it lives on. ## Using the watcher without Arc’s MongoDB configuration A library or host that brings its own `IMongoDatabase` and observes its collections with the collection extensions needs only the watcher, not the rest of the MongoDB integration: ```csharp services.AddMongoDBWatcher(); ``` It registers the same singleton `UseCratisMongoDB()` does, so calling both registers it once. Without the MongoDB configuration, `IMongoDBWatcher.Observe()` throws `MongoDBWatcherNotConfigured`, because it has no configured database to watch; the collection extensions work, since they name their database themselves. ## Prerequisites * A MongoDB replica set or sharded cluster supporting change streams, with database-level watch/read permissions * Arc’s MongoDB integration registered at startup, through either `builder.UseCratisMongoDB()` or `builder.AddCratisArc(configureBuilder: arc => arc.WithMongoDB())`; both register the watcher The examples below are service/query fragments using your application’s `Book`, `Author`, and `Publisher` types. Import `Cratis.Arc.MongoDB` and `System.Reactive.Subjects`; the controller example additionally uses `Microsoft.AspNetCore.Mvc`. They are not complete host programs. ## Observing a single collection Inject `IMongoDBWatcher` and call `Observe()` to start watching a collection. Chain `.Join()` to include additional collections, then call `.Select()` to combine them into a result subject. Even when you only care about one collection, you must call `.Join().Select()` to materialize the subject — a single `Observe()` alone returns a builder, not a subject. ### Example — watch one collection alongside a lookup ```csharp public class BookCatalogService(IMongoDBWatcher watcher) { public ISubject GetLiveCatalog() { return watcher .Observe() .Join() .Select((books, authors) => new BookCatalog(books, authors)); } } public record BookCatalog(IEnumerable Books, IEnumerable Authors); ``` `Select()` is called once with the full current contents of each collection. Changes to any watched collection trigger a fresh joined snapshot, including inserts, updates, replacements, drops, deletes, and renames into or out of an observed namespace. Pending notifications may be coalesced: this is a live snapshot, not a delivery of every intermediate state. ## Filtering observed documents Pass a filter expression to `Observe()` or `Join()` to restrict which documents are loaded when the subject re-fetches after a change. ```csharp public ISubject GetLivePublishedCatalog() { return watcher .Observe(book => book.IsPublished) .Join(author => author.IsActive) .Select((books, authors) => new BookCatalog(books, authors)); } ``` > Filters apply to the re-fetch query, not to the change stream trigger. Any change to a collection causes a re-fetch regardless of whether the changed document matches the filter. ## Joining three collections Chain a second `.Join()` to watch three collections at once. ```csharp public ISubject GetLiveLibrarySummary() { return watcher .Observe() .Join() .Join() .Select((books, authors, publishers) => new LibrarySummary(books, authors, publishers)); } public record LibrarySummary( IEnumerable Books, IEnumerable Authors, IEnumerable Publishers); ``` ## Using the subject in a query `ISubject` implements both `IObservable` and `IObserver`. You can pass it directly to any observable query: ```csharp [Route("api/catalog")] public class CatalogController(IMongoDBWatcher watcher) : Controller { [HttpGet("live")] public ISubject LiveCatalog() { return watcher .Observe(book => book.IsPublished) .Join() .Select((books, authors) => new BookCatalog(books, authors)); } } ``` ## Lifetime and disposal `IMongoDBWatcher` is a singleton. The host owns the singleton and disposes it at shutdown. A failed database watch reconnects with exponential backoff (one second up to 60 seconds), carrying its last resume token. An invalidation or an expired/invalid resume token discards that token, reopens the stream, and refreshes every joined observation. Tokens are held in memory, not as durable consumer checkpoints. Individual subjects returned by `Select()` run a background loop that is tied to the subject itself — the loop stops when the subject completes or errors. Unsubscribing all observers from a subject causes it to complete and cleans up its internal resources automatically. A failed joined snapshot is logged and retried up to five times, with delays of 1, 2, 4, 8, and 16 seconds. If all six attempts fail, Arc logs an error and retains the subscription; the next relevant database notification starts another snapshot attempt. Disposing the subject or removing its last subscriber cancels pending retries. Selector failures still terminate the result subject through `OnError`. These rules apply to the shared watcher, not the per-collection `IMongoCollection.Observe()` extension. Keep tenant-specific results inside their intended lifetime even though the watcher itself is singleton. ## See also * [Observing Collections](/arc/backend/csharp/mongodb/observing-collections/) — Per-collection `IMongoCollection.Observe()` extension methods for single-collection scenarios. * [Getting Started](/arc/backend/csharp/mongodb/getting-started/) — How to register the MongoDB integration, including `IMongoDBWatcher`. # Class mapping > Customize BSON members with discovered maps and type-compatible serializers. Use a class map when one document needs different BSON names or serializers from the rest of your application. Arc discovers `IBsonClassMapFor` implementations during [MongoDB setup](/arc/backend/csharp/mongodb/getting-started/). ## Basic class map example These are complete document/map declarations. The naming-convention opt-out prevents Arc’s subsequent member-convention pass from replacing the custom `username` name. ```csharp using Cratis.Arc.MongoDB; using MongoDB.Bson; using MongoDB.Bson.Serialization; [IgnoreConventions(NamingPolicyNameConvention.ConventionName)] public class User { public ObjectId Id { get; set; } public required string UserName { get; set; } public DateTimeOffset CreatedAt { get; set; } public string? InternalNote { get; set; } } public class UserClassMap : IBsonClassMapFor { public void Configure(BsonClassMap classMap) { classMap.AutoMap(); classMap.SetIdMember(classMap.GetMemberMap(user => user.Id)); classMap.GetMemberMap(user => user.UserName).SetElementName("username"); classMap.UnmapMember(user => user.InternalNote); classMap.GetMemberMap(user => user.CreatedAt) .SetSerializer(new DateTimeOffsetSupportingBsonDateTimeSerializer()); } } ``` The serializer matches `DateTimeOffset`, not `DateTime`. Its default BSON DateTime stores UTC milliseconds, losing the original offset and submillisecond precision. A `DateTime` property instead needs a `DateTimeSerializer`; see [serializers](/arc/backend/csharp/mongodb/serializers/). Unmapping a property excludes it from this document’s persistence; it is not an authorization or HTTP-response redaction mechanism. Use dedicated response models for sensitive data. ## Discovery and registration 1. Arc discovers `IBsonClassMapFor` types. 2. It instantiates each provider with `Activator.CreateInstance`; providers need a usable parameterless constructor, not injected services. 3. If the model already has a registered class map, Arc skips it. 4. Otherwise it registers the map using `Configure`, then applies registered **member-map conventions** again through `ApplyConventions()`. Initialize once before serialization freezes maps. `AutoMap()` applies driver conventions; Arc’s later member pass can overwrite manual names or serializers if a convention targets that member. Filter or ignore conflicting conventions deliberately rather than relying on registration order. ## Inheritance mapping Mapping fragment inside `IBsonClassMapFor.Configure`, using your application’s `Document`, `TextDocument`, and `ImageDocument` types: ```csharp classMap.AutoMap(); classMap.SetIsRootClass(true); classMap.AddKnownType(typeof(TextDocument)); classMap.AddKnownType(typeof(ImageDocument)); ``` Derived maps may call `SetDiscriminator("text")` or another stable value. Discriminators are a persisted contract: test polymorphic reads and existing documents before changing them. Arc’s derived-type conventions also participate where derived types are registered. ## Custom serializers in class maps Inside a map for an entity whose `Price` is `decimal`: ```csharp classMap.GetMemberMap(product => product.Price) .SetSerializer(new MongoDB.Bson.Serialization.Serializers.DecimalSerializer(BsonType.Decimal128)); ``` For an enum property, use `EnumSerializer(BsonType.String)` if strings are the intended persisted representation. For dictionaries and `object` members, choose a driver serializer compatible with the member’s declared type and explicit allowed types; do not use nonexistent `ObjectSerializationOptions` constructor arguments or an unrestricted polymorphic allowlist by default. A class map does **not** create database indexes. Use `collection.Indexes.CreateOneAsync(...)` separately during database setup. ## Convention integration Arc exposes `classMap.ApplyConventions()` for manually reapplying registered member conventions. Normal Arc setup already calls it after `Configure`; do not repeat it without a reason. See [convention filtering](/arc/backend/csharp/mongodb/convention-packs/#filtering-conventions) and [naming policies](/arc/backend/csharp/mongodb/naming-policies/). ## Testing class maps After normal registration, this assertion fragment verifies the example’s BSON contract without connecting to MongoDB: ```csharp var user = new User { UserName = "testuser", CreatedAt = DateTimeOffset.UtcNow, InternalNote = "not persisted" }; var document = user.ToBsonDocument(); if (!document.Contains("_id") || !document.Contains("username") || document.Contains("InternalNote")) { throw new InvalidOperationException("Unexpected User BSON mapping."); } ``` Run serialization checks in an isolated test process or initialize global BSON conventions once. Add round-trip assertions for custom serializers and legacy documents, not just field-name checks. Continue with [concepts](/arc/backend/csharp/mongodb/concepts/) or [convention packs](/arc/backend/csharp/mongodb/convention-packs/). # Concepts in MongoDB > Store strongly typed standalone value records with explicit BSON and nullability contracts. Use `ConceptAs` to distinguish values such as a user ID and a product name without storing a wrapper document. Arc’s MongoDB integration registers `ConceptSerializationProvider` during setup; Chronicle is not needed. ## Declare value records ```csharp using Cratis.Concepts; public record UserId(Guid Value) : ConceptAs(Value) { public static readonly UserId NotSet = new(Guid.Empty); public static implicit operator UserId(Guid value) => new(value); public static UserId New() => new(Guid.NewGuid()); } public record ProductName(string Value) : ConceptAs(Value) { public static readonly ProductName NotSet = new(string.Empty); public static implicit operator ProductName(string value) => new(value); } ``` These are complete type declarations. `ConceptAs` is a record, so derive a **record**, not a class. A standalone Guid-backed identity such as `UserId` is valid; do not replace every Guid concept with a Chronicle identity. ## Store primitive values Model declaration using the types above: ```csharp public class Product { public required UserId OwnerId { get; set; } public required ProductName Name { get; set; } public UserId? ReviewerId { get; set; } } ``` `Name` is stored as a BSON string. Guid concepts are stored as Standard UUID **binary**, not a JSON string. A null concept reference is stored as BSON null. Actual element names follow your [naming policy](/arc/backend/csharp/mongodb/naming-policies/) and ID mapping. Use nullable concept references for optional values (`UserId?`). `ConceptAs` is invalid because nullable Guid does not meet the primitive generic constraint. Do not confuse the concept’s non-null sentinel (`NotSet`) with an absent/null concept. ## Serialization and validation `ConceptSerializer` unwraps values on writes and reconstructs concepts on reads. It can read the primitive form and a legacy wrapper document containing `Value` or `value`. Construction with a non-concept type, such as `new ConceptSerializer()`, throws `TypeIsNotAConcept`. BSON representation and range limits still apply to the underlying primitive. Custom serializer availability alone does not guarantee every possible `ConceptAs` behaves identically: verify representative values and nulls for your type. See [serializers](/arc/backend/csharp/mongodb/serializers/), especially date/time precision. Do not define both a positional record constructor and another constructor with the same `string` signature. A simple email value type is: ```csharp using Cratis.Concepts; public record EmailAddress(string Value) : ConceptAs(Value) { public static implicit operator EmailAddress(string value) => new(value); } ``` This declaration does **not** validate email syntax. Put input rules in the application’s validation layer; BSON serialization is not command validation. Collections of concepts also use their underlying values, but dictionary key representation must satisfy the driver’s dictionary rules—do not assume arbitrary concept keys work with document-form dictionaries. ## Query with typed values Method fragment for an injected `IMongoCollection` named `collection` (`MongoDB.Driver` imported): ```csharp var ownerId = UserId.New(); var products = await collection.Find(product => product.OwnerId == ownerId).ToListAsync(); ``` The member serializer renders the concept as its BSON primitive. A newly generated ID normally returns no rows unless matching data exists. ## Optional Chronicle integration If the identifier represents a Chronicle event source, use that integration’s [event-source ID conventions](/arc/backend/csharp/chronicle/resolving-event-source-id/). That is an additional event-sourcing contract, not a MongoDB bootstrap requirement or a restriction on ordinary Guid concepts. Continue with [class mapping](/arc/backend/csharp/mongodb/class-mapping/) and [convention packs](/arc/backend/csharp/mongodb/convention-packs/). # Connection resolution > Resolve the MongoDB server, database and identity per tenant through configuration, a template, an HTTP endpoint or your own resolver. Use connection resolution when each tenant’s MongoDB database lives somewhere your platform decides, or authenticates with its own credentials. For the concepts and the resolvers, read [connection resolution](/arc/backend/csharp/tenancy/connection-resolution/) first; for the endpoint an `Http` resolver calls, see the [connection resolution contract](/arc/connection-resolution-contract/). This page configures resolution for MongoDB only. To configure it once for every storage, set it on Arc instead, as described in [configure it once for the whole application](/arc/backend/csharp/tenancy/connection-resolution/#configure-it-once-for-the-whole-application). What you configure here replaces the application-wide configuration for MongoDB. ## Fetch the identity from your platform Keep `Server` and `Database` as they are, and resolve only who connects: ```json { "Cratis": { "MongoDB": { "Server": "mongodb://cluster.example:27017", "Database": "app", "ConnectionResolution": { "Identity": { "Resolver": "Http", "Http": { "Endpoint": "https://platform.example/connections", "Headers": { "X-Api-Key": "from-your-secret-store" } } } } } } } ``` The same in code: ```csharp builder.AddCratisArc(configureBuilder: arc => arc.WithMongoDB( configureOptions: mongo => mongo.ConnectionResolution.Identity.UseHttp(new Uri("https://platform.example/connections")))); ``` Each request scope now resolves for its tenant. `IMongoClient`, `IMongoDatabase` and `IMongoCollection` in that scope all come from the same resolution. ## What Arc applies | Resolved part | Effect | | --- | --- | | `ConnectionString` | Replaces `Server`. | | `Database` | Replaces the database name. Without it, the [tenant database naming](/arc/backend/csharp/mongodb/tenancy/) applies as before. | | `Identity.UserName` and `Identity.Password` | Becomes a SCRAM credential. It authenticates against the connection string’s `authSource`, else its database, else `admin`. | | `Identity.AccessToken` | Becomes a `MONGODB-OIDC` credential. When the driver needs a new token, Arc resolves again, so a rotating token never needs a new client. | | `Schema` | Not used by MongoDB. | Clients are shared per server and credential, never across credentials. A connection that fails to authenticate drops the cached resolution, and the next scope resolves fresh credentials. Change-stream [observation](/arc/backend/csharp/mongodb/observing-collections/) follows the same resolution, partitioned per server and database. ## Place tenants with a template When placement follows a rule, you do not need an endpoint: ```json "ConnectionResolution": { "Connection": { "Resolver": "Template", "Template": { "Database": "tenant-{tenant}" } } } ``` Resolution replaces the default tenant naming A resolved `Database` replaces the `{Database}+{tenant}` naming entirely, including for the default tenant, which resolves as `Default`. Data already stored under the old names stays there. Treat switching as a data migration. # Convention packs > Provide and filter process-wide MongoDB mapping conventions. Convention packs apply consistent rules across MongoDB class maps. Arc discovers pack providers and filters during MongoDB setup. The examples below are provider/type declarations or illustrative configuration/test fragments for a configured host, not independent programs. Import `Cratis.Arc.MongoDB`, `MongoDB.Bson`, `MongoDB.Bson.Serialization`, `MongoDB.Bson.Serialization.Conventions`, and `MongoDB.Bson.Serialization.Serializers` as needed. ## What are Convention Packs? Convention packs are collections of conventions that MongoDB applies automatically to class maps during registration. They allow you to: * **Apply consistent rules**: Ensure all classes follow the same patterns * **Reduce boilerplate**: Avoid repeating configuration in every class map * **Conditional application**: Apply different rules to different types * **System-wide changes**: Modify behavior across your entire application ## Built-in Convention Packs Cratis Applications automatically registers several convention packs: ### Naming Policy Convention Applies your configured naming policy to all property names. The `RegisterConventionAsPack` calls in this built-in section describe private Arc initialization; they are not public APIs to call from application code: ```csharp // Registered automatically with name: "Naming policy based name convention" RegisterConventionAsPack( conventionPackFilters, NamingPolicyNameConvention.ConventionName, new NamingPolicyNameConvention() ); ``` ### Ignore Extra Elements Ignores unknown properties during deserialization: ```csharp // Registered automatically with name: "Ignore extra elements convention" RegisterConventionAsPack( conventionPackFilters, ConventionPacks.IgnoreExtraElements, new IgnoreExtraElementsConvention(true) ); ``` ### Read Model Collections Never Null Materializes a `[ReadModel]`’s declared **non-nullable** collection members as empty collections instead of `null`: ```csharp // Registered automatically with name: "Read model collections never null convention", // scoped to types marked with [ReadModel] RegisterConventionAsPack( conventionPackFilters, ConventionPacks.ReadModelCollectionsNeverNull, new ReadModelCollectionsNeverNullConvention(), type => type.IsReadModel() ); ``` #### Why it exists — a separate serialization boundary This convention works for standalone Arc `[ReadModel]` documents too. Chronicle is one optional producer of the missing/null shapes described below, not a prerequisite for using the convention. A read model that declares a child collection as a non-nullable `IEnumerable` promises the type system that the value is there. The store is free to disagree. Chronicle’s read model sink writes **no field at all** for a child collection that has never had an element, and can write an **explicit `null`** for one whose last element went away. Nullable reference analysis has already concluded the member can never be `null`, so nothing warns, and an unguarded `.Any()` / `.All()` / `.Select()` throws at runtime. Chronicle closes this for **its own reader** with a `JsonTypeInfo` modifier on its client’s `JsonSerializerOptions`. That fix lives at Chronicle’s serialization boundary and **does not reach the MongoDB driver**. Reading a read model through `IMongoCollection` — the sanctioned way to query by anything other than the key, and what Arc’s own server-side paging runs on — goes through the driver instead. This convention is the same guarantee, restated where the driver can honor it. #### What it covers Both shapes a store can leave behind, through two different mechanisms: | Stored shape | Mechanism | Result for a non-nullable member | | --- | --- | --- | | The field is **absent** from the document | a default value on the member map | an empty collection | | The field is present and holds **`null`** | a serializer wrapping the member’s own | an empty collection | Both halves are needed: the driver never invokes a member’s serializer for an element that is not in the document, and a default value is never consulted for one that is. #### What it deliberately leaves alone * **Nullable collections.** A member declared `IEnumerable?` keeps the distinction between “no collection” and “an empty collection”, because that model asked for it. Absent stays `null`; a stored `null` stays `null`. * **Dictionaries.** `IDictionary`, `IReadOnlyDictionary`, and `Dictionary` are untouched. * **`string`.** It is an `IEnumerable`, and the classic trap here would be handing it an empty list. * **Types without `[ReadModel]`.** The scoping is what keeps this from redefining what `null` means for every BSON type in the process. * **Writing.** Serialization is unchanged — a collection that is `null` in memory is still written as `null`. #### Supported collection shapes `IEnumerable`, `ICollection`, `IList`, `IReadOnlyCollection`, `IReadOnlyList`, `List`, `ISet`, `IReadOnlySet`, `HashSet`, and single-dimension arrays `T[]`. Interface-typed members are filled with a `List` (or a `HashSet` for the set interfaces). Any other collection type is left as the driver would leave it — the empty value is assigned straight into the member, so a shape that cannot be constructed safely is not guessed at. #### Opting out Per type, through the standard mechanism: ```csharp [IgnoreConventions(ConventionPacks.ReadModelCollectionsNeverNull)] [ReadModel] public record RawProjection(string Id, IEnumerable Children); ``` Per member, by stating the default the member wants — the convention skips any member carrying a `[BsonDefaultValue]`, for an absent element and a stored `null` alike: ```csharp [ReadModel] public record Parent(string Id, [property: BsonDefaultValue(null)] IEnumerable Children); ``` #### Things to know * **The default replaces whatever a property initializer set.** A member written as `public IEnumerable Children { get; set; } = [];` still comes back empty, but as a `List` rather than the `Child[]` the collection expression produced. A member whose initializer sets a **meaningful non-empty** default would be silently emptied — use one of the opt-outs above for that member. * **Registration order matters.** A class map freezes its conventions the first time it is built, so this is registered during `AddCratisMongoDB`, before anything can deserialize. ## Creating Convention Pack Providers To provide your own convention packs, implement `ICanProvideMongoDBConventionPacks`: ```csharp public interface ICanProvideMongoDBConventionPacks { IEnumerable Provide(); } ``` ### Example Provider ```csharp public class CustomConventionPackProvider : ICanProvideMongoDBConventionPacks { public IEnumerable Provide() { // Enum string serialization yield return new MongoDBConventionPackDefinition( "Enum Conventions", new ConventionPack { new EnumRepresentationConvention(BsonType.String) } ); // Custom discriminator handling yield return new MongoDBConventionPackDefinition( "Custom Discriminator", new ConventionPack { new CustomDiscriminatorConvention( CustomObjectDiscriminatorConvention.Instance, GetTypesWithExistingDiscriminators() ) } ); } private static IEnumerable GetTypesWithExistingDiscriminators() { // Return types that already have discriminator configuration yield return typeof(BaseDocument); yield return typeof(AuditableEntity); } } ``` ### Advanced Convention Examples ```csharp public class DomainConventionPackProvider : ICanProvideMongoDBConventionPacks { public IEnumerable Provide() { // ID field conventions yield return new MongoDBConventionPackDefinition( "ID Field Conventions", new ConventionPack { new NamedIdMemberConvention("Id", "id", "_id"), new StringObjectIdIdGeneratorConvention() } ); // Ignore null values yield return new MongoDBConventionPackDefinition( "Ignore Null Values", new ConventionPack { new IgnoreIfNullConvention(true) } ); // Custom date handling yield return new MongoDBConventionPackDefinition( "Date Conventions", new ConventionPack { new DateTimeUtcConvention() } ); } } ``` ## Filtering Conventions You can control which types convention packs apply to using filters. ### ICanFilterMongoDBConventionPacksForType Implement this interface to create custom filters: ```csharp public interface ICanFilterMongoDBConventionPacksForType { bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type); } ``` ### Example Filters ```csharp public class DomainModelFilter : ICanFilterMongoDBConventionPacksForType { public bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { // Only apply naming conventions to domain models if (conventionPackName == NamingPolicyNameConvention.ConventionName) { return type.Namespace?.Contains("Domain.Models") == true; } return true; } } public class NoConventionsForDTOs : ICanFilterMongoDBConventionPacksForType { public bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { // Don't apply any conventions to DTOs if (type.Name.EndsWith("DTO") || type.Name.EndsWith("Dto")) { return false; } return true; } } public class LegacySystemFilter : ICanFilterMongoDBConventionPacksForType { public bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { // Don't apply naming conventions to legacy types if (conventionPackName == NamingPolicyNameConvention.ConventionName && type.Namespace?.Contains("Legacy") == true) { return false; } return true; } } ``` ## IgnoreConventions Attribute For fine-grained control, use `IgnoreConventions` on specific types. This filters the packs that participate in Arc’s convention filtering; it does not disable global BSON serializers, ordinary driver conventions, or Arc’s deliberately unfiltered derived-type discriminator convention. ### Ignore All Conventions ```csharp [IgnoreConventions] public class RawDocument { public string _id { get; set; } // Keep exact field names public string user_name { get; set; } // No naming policy applied public object extra_data { get; set; } // No serialization conventions } ``` ### Ignore Specific Conventions ```csharp [IgnoreConventions(NamingPolicyNameConvention.ConventionName)] public class ExactFieldNames { public string UserName { get; set; } // Stored as "UserName" public string EmailAddr { get; set; } // Stored as "EmailAddr" } [IgnoreConventions(ConventionPacks.IgnoreExtraElements)] public class StrictDocument { public string Name { get; set; } // Will throw exception if extra fields are present during deserialization } ``` ### Multiple Ignore Attributes ```csharp [IgnoreConventions(NamingPolicyNameConvention.ConventionName)] [IgnoreConventions("Custom Enum Convention")] public class SpecialDocument { public string PropertyName { get; set; } // No naming policy public MyEnum Status { get; set; } // No enum convention } ``` ## Built-in Convention Pack Names The framework defines constants for well-known convention pack names: ```csharp public static class ConventionPacks { public const string IgnoreExtraElements = "Ignore extra elements convention"; public const string ReadModelCollectionsNeverNull = "Read model collections never null convention"; } public class NamingPolicyNameConvention { public const string ConventionName = "Naming policy based name convention"; } ``` ## Advanced Convention Pack Examples ### Audit Field Conventions ```csharp public class AuditConventionPackProvider : ICanProvideMongoDBConventionPacks { public IEnumerable Provide() { yield return new MongoDBConventionPackDefinition( "Audit Fields", new ConventionPack { new AuditFieldConvention() } ); } } public class AuditFieldConvention : ConventionBase, IMemberMapConvention { public void Apply(BsonMemberMap memberMap) { var memberName = memberMap.MemberName; // Auto-configure audit fields if ((memberName == "CreatedAt" || memberName == "UpdatedAt") && memberMap.MemberType == typeof(DateTimeOffset)) { memberMap.SetSerializer(new DateTimeOffsetSupportingBsonDateTimeSerializer()); if (memberName == "CreatedAt") { memberMap.SetIgnoreIfDefault(true); } } // Configure user audit fields if (memberName == "CreatedBy" || memberName == "UpdatedBy") { memberMap.SetIgnoreIfNull(true); } } } ``` The date convention above only targets `DateTimeOffset` members; attaching that serializer to a `DateTime` member is incompatible. The default representation loses offsets and submillisecond precision; see [serializers](/arc/backend/csharp/mongodb/serializers/). ### Validation Conventions ```csharp public class ValidationConventionPackProvider : ICanProvideMongoDBConventionPacks { public IEnumerable Provide() { yield return new MongoDBConventionPackDefinition( "Required Fields", new ConventionPack { new RequiredFieldConvention() } ); } } public class RequiredFieldConvention : ConventionBase, IMemberMapConvention { public void Apply(BsonMemberMap memberMap) { var memberInfo = memberMap.MemberInfo; // Check for Required attribute if (memberInfo.GetCustomAttribute() != null) { memberMap.SetIgnoreIfDefault(false); memberMap.SetIgnoreIfNull(false); } } } ``` The `RequiredFieldConvention` example controls omission on **writes**; it does not validate commands or require a field on **reads**. Use explicit validation and, where appropriate, a required BSON member contract for those separate requirements. ## Registration and Lifecycle ### Automatic Discovery Convention pack providers are automatically discovered during setup and instantiated with `Activator.CreateInstance`; use parameterless constructors. The following discovery sketch describes internals, not extra startup code to paste: ```csharp // This happens during UseCratisMongoDB() var types = Types.Instance; var providers = types.FindMultiple(); var filters = types.FindMultiple(); // Providers and filters are registered automatically ``` ### Registration Order Convention packs are registered in the order they’re provided. If multiple conventions affect the same aspect, later conventions may override earlier ones. ### Filter Application For each convention pack, all filters are consulted: ```csharp static bool ShouldInclude( IEnumerable filters, string conventionPackName, IConventionPack conventionPack, Type type) { // All filters must return true for the convention pack to be applied return filters.All(filter => filter.ShouldInclude(conventionPackName, conventionPack, type)); } ``` ## Performance Considerations ### Filter Efficiency Convention pack filters are called for every type, so keep them efficient: ```csharp // Good: Simple, fast checks public bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { return !type.Name.EndsWith("DTO"); } // Avoid: Expensive operations public bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { return !type.GetCustomAttributes().Any(attr => attr is DTOAttribute); } ``` ### Caching Results This illustrative fragment assumes your application supplies `ExpensiveCheck(Type)`. Cache only process-stable decisions, never tenant/request authorization decisions: ```csharp public class CachedFilter : ICanFilterMongoDBConventionPacksForType { private static readonly ConcurrentDictionary<(string, Type), bool> _cache = new(); public bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { return _cache.GetOrAdd((conventionPackName, type), key => ComputeShouldInclude(key.Item1, conventionPack, key.Item2)); } private bool ComputeShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { // Expensive computation here return ExpensiveCheck(type); } } ``` ## Testing Convention Packs You can test your convention packs to ensure they work correctly: ```csharp [Test] public void should_apply_naming_convention_to_domain_models() { // Arrange var classMap = new BsonClassMap(); classMap.AutoMap(); // Act classMap.ApplyConventions(); // Assert var memberMap = classMap.GetMemberMap(m => m.PropertyName); memberMap.ElementName.ShouldEqual("propertyName"); // camelCase applied } [Test] public void should_ignore_conventions_when_attribute_present() { // Arrange var classMap = new BsonClassMap(); classMap.AutoMap(); // Act classMap.ApplyConventions(); // Assert var memberMap = classMap.GetMemberMap(m => m.PropertyName); memberMap.ElementName.ShouldEqual("PropertyName"); // No naming convention applied } ``` ## Best Practices ### Keep Conventions Simple Each convention should have a single responsibility: ```csharp // Good: Single purpose public class DateTimeUtcConvention : ConventionBase, IMemberMapConvention { public void Apply(BsonMemberMap memberMap) { if (memberMap.MemberType == typeof(DateTime)) { memberMap.SetSerializer(new DateTimeSerializer(DateTimeKind.Utc)); } } } // Avoid: Multiple concerns public class MegaConvention : ConventionBase, IMemberMapConvention { public void Apply(BsonMemberMap memberMap) { // Handles dates, strings, numbers, etc. - too complex } } ``` ### Use Descriptive Names Convention pack names should clearly indicate their purpose: ```csharp // Good: Clear naming "Audit Field Conventions" "Required Field Validation" "Legacy System Compatibility" // Avoid: Vague naming "Custom Convention" "Special Rules" "Fixes" ``` ### Document Filter Logic Make filter logic clear and well-documented: ```csharp public class ApiModelFilter : ICanFilterMongoDBConventionPacksForType { /// /// Applies naming conventions only to API models (types ending with "ApiModel") /// and excludes internal types from convention processing. /// public bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { if (conventionPackName == NamingPolicyNameConvention.ConventionName) { return type.Name.EndsWith("ApiModel") && !type.IsNotPublic; } return true; } } ``` ## Next Steps * Learn about [Class Mapping](/arc/backend/csharp/mongodb/class-mapping/) for type-specific configurations * Explore [Concepts](/arc/backend/csharp/mongodb/concepts/) for domain-driven design patterns * Understand [Naming Policies](/arc/backend/csharp/mongodb/naming-policies/) for consistent property naming * Review [Serializers](/arc/backend/csharp/mongodb/serializers/) for custom type handling # Geospatial Types > Store and serialize geographic data with Point, LineString, and Polygon types Arc provides BSON serializers for types from `Cratis.Geospatial`. Their writers produce GeoJSON for MongoDB storage and spatial filters. Before using Polygon, read the [current Polygon reader limitation](/arc/backend/csharp/mongodb/geospatial/polygon/#current-read-limitation): typed reads fail on the coordinate nesting its writer emits. ## Supported Types * **[Point](/arc/backend/csharp/mongodb/geospatial/point/)** — Single geographic coordinates (longitude, latitude) * **[LineString](/arc/backend/csharp/mongodb/geospatial/linestring/)** — Routes and paths (ordered sequences of points) * **[Polygon](/arc/backend/csharp/mongodb/geospatial/polygon/)** — Geographic areas with optional interior boundaries (holes) ## GeoJSON Compatibility Arc’s BSON serializers write GeoJSON documents. This does not mean the Cratis model types are accepted as geometry arguments by MongoDB driver’s filter builders: use `MongoDB.Driver.GeoJsonObjectModel` types for those arguments, as the individual guides show. The records and write paths do not validate geometry; the Polygon reader contains some ring checks, not comprehensive validation. Server spatial-index restrictions still apply. Optional geometry also needs an explicit [null/omission policy](/arc/backend/csharp/mongodb/serializers/#error-handling), not just a nullable annotation. GeoJSON storage enables: * **MongoDB Spatial Queries** — Use `$near`, `$geoWithin`, and other spatial operators * **Interoperability** — Exchange GeoJSON with compatible mapping services and GIS tools * **Standards Compliance** — Follow industry-standard GeoJSON specification ## Getting Started Choose the type that matches your use case: * Use **Point** for storing single locations (stores, users, events) * Use **LineString** for routes, paths, or trajectories * Use **Polygon** for service areas, regions, or geographic boundaries See the individual type documentation for implementation examples and best practices. # LineString serializer > Store routes as GeoJSON and query them using driver geometry. Arc’s `LineStringSerializer` stores a Cratis line as GeoJSON. Use it for paths, not for computed distance along a route. Complete [MongoDB setup](/arc/backend/csharp/mongodb/getting-started/) before these model/query fragments. ## Usage ```csharp using Cratis.Geospatial; using MongoDB.Bson; public class DeliveryRoute { public ObjectId Id { get; set; } public required LineString Path { get; set; } } ``` Construction fragment inside a method: ```csharp var route = new LineString([ new Point(-122.4194, 37.7749), new Point(-122.4185, 37.7750), new Point(-122.4170, 37.7755) ]); ``` ## Storage format With camel-case naming, the path member is: ```json {"path":{"type":"LineString","coordinates":[[-122.4194,37.7749],[-122.4185,37.775],[-122.417,37.7755]]}} ``` Provide at least two points in their intended order. The Cratis record/serializer does not validate that count or geographic ranges. MongoDB may reject invalid geometry when indexing or querying it. ## Querying For an `IMongoCollection` named `collection`, this method fragment finds nearby routes: ```csharp using MongoDB.Driver; using MongoDB.Driver.GeoJsonObjectModel; await collection.Indexes.CreateOneAsync(new CreateIndexModel( Builders.IndexKeys.Geo2DSphere(route => route.Path))); var origin = new GeoJsonPoint( new GeoJson2DGeographicCoordinates(-122.418, 37.775)); var nearby = await collection.Find(Builders.Filter.Near( route => route.Path, origin, maxDistance: 1000)).ToListAsync(); ``` The filter argument is a **driver GeoJSON point**, not a Cratis point. Perform index creation during setup. The radius is in meters for this GeoJSON query; it is not distance traveled along the line. To find routes crossing a region, use `GeoIntersects` with a driver GeoJSON polygon. `GeoWithin` instead asks whether the stored geometry is contained within the query area. See [polygon construction and queries](/arc/backend/csharp/mongodb/geospatial/polygon/#querying). ## Limits Serialization supplies a GeoJSON shape, not validation, a routing engine, or guaranteed query translation from arbitrary geometry methods. Test boundary cases and spatial query results in your target MongoDB version. `LineString?` expresses CLR optionality only. Arc’s current geometry BSON serializers neither serialize a null value nor read explicit BSON null. Design and test an omission policy or a null-aware member serializer before persisting optional geometry; a nullable annotation alone is insufficient. See [serializer null handling](/arc/backend/csharp/mongodb/serializers/#error-handling). See [Point](/arc/backend/csharp/mongodb/geospatial/point/) and [Polygon](/arc/backend/csharp/mongodb/geospatial/polygon/) for the other supported geometry types. # Point serializer > Store GeoJSON points and use MongoDB driver geometry in query filters. Arc stores `Cratis.Geospatial.Point` as GeoJSON. Complete [MongoDB setup](/arc/backend/csharp/mongodb/getting-started/) first; the snippets below are model declarations and query-method fragments, not a standalone host. ## Usage ```csharp using Cratis.Geospatial; using MongoDB.Bson; public class Store { public ObjectId Id { get; set; } public required string Name { get; set; } public required Point Location { get; set; } } ``` Inside a method, construct a point with positional arguments or the correctly capitalized names: ```csharp var point = new Point(Longitude: -122.4194, Latitude: 37.7749); ``` ## Storage format With camel-case naming, the location member is: ```json {"location":{"type":"Point","coordinates":[-122.4194,37.7749]}} ``` Coordinates are `[longitude, latitude]`. Validate ranges in your application; the record/serializer does not enforce geographic validity. `Point?` expresses CLR optionality only. Arc’s current geometry BSON serializers neither serialize a null value nor read explicit BSON null. Design and test an omission policy or a null-aware member serializer before persisting optional geometry; a nullable annotation alone is insufficient. See [serializer null handling](/arc/backend/csharp/mongodb/serializers/#error-handling). ## Querying The driver’s geometry arguments are **`GeoJsonPoint`**, not Cratis `Point`. The following fragment uses an `IMongoCollection` named `collection`: ```csharp using MongoDB.Driver; using MongoDB.Driver.GeoJsonObjectModel; await collection.Indexes.CreateOneAsync(new CreateIndexModel( Builders.IndexKeys.Geo2DSphere(store => store.Location))); var origin = new GeoJsonPoint( new GeoJson2DGeographicCoordinates(-122.4, 37.78)); var nearbyStores = await collection.Find(Builders.Filter.Near( store => store.Location, origin, maxDistance: 5000)).ToListAsync(); ``` This uses a `2dsphere` index and a GeoJSON query point, so the distance bound is in meters. Index creation is normally deployment/setup work, not something to repeat on every query. To find stored points within a region, use `GeoWithin` with a **driver GeoJSON polygon**. See [polygon queries](/arc/backend/csharp/mongodb/geospatial/polygon/#querying) for construction and the distinction between containment and intersection. These examples establish API shape; test actual spatial results against your MongoDB deployment. Continue with [LineString](/arc/backend/csharp/mongodb/geospatial/linestring/) for paths or [Polygon](/arc/backend/csharp/mongodb/geospatial/polygon/) for areas. # Polygon serializer > Store GeoJSON boundaries and distinguish containment from intersection queries. Use a polygon for an area with an exterior ring and optional holes. Arc supplies BSON serialization; your application supplies valid geometry. Complete [MongoDB setup](/arc/backend/csharp/mongodb/getting-started/) before these model/query fragments. ## Usage ```csharp using Cratis.Geospatial; using MongoDB.Bson; public class ServiceArea { public ObjectId Id { get; set; } public required Polygon Boundary { get; set; } } ``` Construction fragment inside a method: ```csharp var boundary = new Polygon( new LinearRing([ new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(0, 0) ]), Holes: []); ``` `Holes` is capitalized in the constructor’s named argument. Supply `LinearRing[]` for holes, not a collection of polygons. ## Storage format With camel-case naming, the boundary member is: ```json { "boundary": { "type": "Polygon", "coordinates": [ [ [0, 0], [1, 0], [0, 1], [0, 0] ] ] } } ``` The first ring is the shell; later rings are holes. Unlike EF’s plain geometry string converters, MongoDB serializers produce GeoJSON numeric coordinate arrays. ## Current read limitation Caution The current Arc Polygon BSON reader does not enter the nested coordinate-pair arrays emitted by its writer. Typed materialization of matching documents as `ServiceArea` fails on this GeoJSON shape. The query examples below demonstrate filter construction, **not working Polygon reads**. Use a corrected, tested serializer before relying on those reads. Writing succeeds, but reading the nested coordinate arrays can throw `InvalidOperationException`: `ReadDouble can only be called when CurrentBsonType is Double, not when CurrentBsonType is Array.` This is a BSON reader limitation, separate from MongoDB’s spatial-query support. To check the serializer’s round-trip behavior, run this in a **separate console process** referencing `Cratis.Arc.MongoDB`, without Arc host initialization. The explicit registration is for this isolated probe only; do not repeat it after Arc setup: ```csharp using Cratis.Arc.MongoDB; using Cratis.Geospatial; using MongoDB.Bson; using MongoDB.Bson.Serialization; BsonSerializer.RegisterSerializer(new PolygonSerializer()); var triangle = new LinearRing([ new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(0, 0) ]); var hole = new LinearRing([ new Point(0.1, 0.1), new Point(0.1, 0.2), new Point(0.2, 0.1), new Point(0.1, 0.1) ]); Polygon[] polygons = [new(triangle, Holes: []), new(triangle, Holes: [hole])]; foreach (var polygon in polygons) { var bson = polygon.ToBson(); try { var restored = BsonSerializer.Deserialize(bson); Console.WriteLine($"Read completed: {restored.Holes.Length} holes; verify coordinates too."); } catch (InvalidOperationException error) { Console.WriteLine($"Read failed: {error.Message}"); } } ``` With the current reader, both iterations print the failure above. A completed read still needs coordinate/topology assertions; this diagnostic does not certify geometry correctness. ## Querying For an `IMongoCollection` named `collection`, create a spatial index during setup and use a **driver GeoJSON point** to find areas intersecting that point: ```csharp using MongoDB.Driver; using MongoDB.Driver.GeoJsonObjectModel; await collection.Indexes.CreateOneAsync(new CreateIndexModel( Builders.IndexKeys.Geo2DSphere(area => area.Boundary))); var point = new GeoJsonPoint( new GeoJson2DGeographicCoordinates(0.1, 0.1)); var containingAreas = await collection.Find(Builders.Filter.GeoIntersects( area => area.Boundary, point)).ToListAsync(); ``` Do not use `GeoWithin(storedPolygon, point)` to invert containment: it asks whether the stored geometry lies within the query geometry, not whether an area contains a point. For overlapping areas, construct a driver polygon and use `GeoIntersects` (not a nonexistent `Intersects` filter method): ```csharp var queryPolygon = new GeoJsonPolygon( new GeoJsonPolygonCoordinates( new GeoJsonLinearRingCoordinates([ new GeoJson2DGeographicCoordinates(0, 0), new GeoJson2DGeographicCoordinates(1, 0), new GeoJson2DGeographicCoordinates(0, 1), new GeoJson2DGeographicCoordinates(0, 0) ]))); var overlapping = await collection.Find(Builders.Filter.GeoIntersects( area => area.Boundary, queryPolygon)).ToListAsync(); ``` For complete containment, substitute `GeoWithin` with that polygon argument. Cratis `Polygon` is the stored model type, not the accepted driver filter geometry type. Test edges, holes, large regions, and boundary points on your target server rather than inferring precise topology semantics from serialization alone. ## Polygon requirements Validate these before storage: closed rings, at least three distinct vertices plus a closing point, valid coordinate ranges, suitable winding, and no invalid self-intersections. The Cratis records and write path do not validate geometry. The read path has minimum-count and closure checks for rings, not comprehensive geometry validation, and currently encounters the reader failure described above. MongoDB’s geospatial validation and index/query restrictions still apply. Use holes for interior exclusions and separate documents for disconnected regions when appropriate. A list of polygons is not automatically a GeoJSON `MultiPolygon`. Continue with [Point](/arc/backend/csharp/mongodb/geospatial/point/) and [LineString](/arc/backend/csharp/mongodb/geospatial/linestring/). # Get started with MongoDB > Add optional MongoDB persistence to standalone Arc with explicit connection settings. When an Arc command or query needs document storage, add `Cratis.Arc.MongoDB`. The integration supplies collection injection, BSON serializers, naming conventions, and observation. **Chronicle and event sourcing are not prerequisites.** ## Install the optional provider Start from a working [ASP.NET Core Arc host](/arc/backend/csharp/asp-net-core/) or [lightweight Arc host](/arc/backend/csharp/core/getting-started/), then install: ```bash dotnet add package Cratis.Arc.MongoDB ``` The MongoDB package currently references the ASP.NET shared framework as well as Arc Core, even though its persistence features do not require Chronicle. ## Configure connection settings In your host’s `appsettings.json`, add: ```json { "Cratis": { "MongoDB": { "Server": "mongodb://localhost:27017", "Database": "library" } } } ``` `Server` and `Database` are required. Keep production credentials in your host’s secret configuration, not source control. `DirectConnection` is optional: when unset, the connection string’s setting is preserved. Direct connection does not turn a standalone MongoDB server into a replica set. ## Enable the integration This is a complete ASP.NET Core `Program.cs` for a project using `Microsoft.NET.Sdk.Web` with `Cratis.Arc` and `Cratis.Arc.MongoDB` installed. Use the settings above and a reachable MongoDB deployment. ```csharp using Cratis.Arc; using Microsoft.AspNetCore.Builder; var builder = WebApplication.CreateBuilder(args); builder.AddCratisArc(configureBuilder: arc => arc.WithMongoDB()); var app = builder.Build(); app.UseCratisArc(); app.Run(); ``` `WithMongoDB()` works through `IArcBuilder`, including the lightweight host’s `AddCratisArc` configuration callback. Retain that host’s normal build, `UseCratisArc()`, and run sequence. For existing ASP.NET/generic-host code, `UseCratisMongoDB()` remains an alternative registration entry point; do not register both paths. A registration checkpoint is that a request/service scope can resolve `IMongoCollection`. A successful database read additionally requires connectivity, permissions, and any application data you expect; starting the host does not populate collections. ## What gets configured * Scoped `IMongoClient`, `IMongoDatabase`, and `IMongoCollection` access. * Default server/database resolvers and unchanged type/member naming. * Guid serialization using `GuidRepresentation.Standard`. * Concept, date/time, geometry, and other [BSON serializers](/arc/backend/csharp/mongodb/serializers/). * Discovered [class maps](/arc/backend/csharp/mongodb/class-mapping/), [convention packs](/arc/backend/csharp/mongodb/convention-packs/), and filters. `DateTimeOffset` uses BSON UTC milliseconds by default: the original offset and submillisecond precision are not preserved. Naming and serializers are process-wide initialization, not per-request customization. ## Customize defaults Alternative startup fragment; custom resolver types must implement the corresponding interfaces: ```csharp builder.AddCratisArc(configureBuilder: arc => arc.WithMongoDB( configureMongoDB: mongo => mongo.WithCamelCaseNamingPolicy())); ``` Import `Cratis.Arc.MongoDB` for MongoDB builder customization methods. Use `WithServerResolver()` and `WithDatabaseResolver()` only when the default configured server and [tenant database naming](/arc/backend/csharp/mongodb/tenancy/) do not meet your requirements. Next, verify [concept serialization](/arc/backend/csharp/mongodb/concepts/) and [class mapping](/arc/backend/csharp/mongodb/class-mapping/). Add [collection observation](/arc/backend/csharp/mongodb/observing-collections/) only after ordinary reads work; change streams require a replica set or sharded cluster and appropriate permissions. # Naming policies > Configure MongoDB collection and member names before persisting data. Naming policies control collection and BSON member names. Configure them once before writing data; changing a policy does not rename existing fields or collections. Examples are policy declarations or startup/query fragments for the [configured host](/arc/backend/csharp/mongodb/getting-started/), not complete programs. Import `Cratis.Serialization` for `INamingPolicy` and `Cratis.Arc.MongoDB` for builder customization. ## Overview Collection names and their members are named based on a naming policy (`INamingPolicy`). The framework provides flexible configuration options and built-in policies for common scenarios. ## Default Behavior The default naming policy does not alter the input, giving you the names exactly as defined in your types: ```csharp public class UserAccount { public string UserName { get; set; } // Stored as: "UserName" public string EmailAddress { get; set; } // Stored as: "EmailAddress" public DateTime CreatedDate { get; set; } // Stored as: "CreatedDate" } ``` ## Built-in Naming Policies ### Camel Case Policy The most common naming policy is camel case, which converts PascalCase property names to camelCase: ```csharp builder.UseCratisMongoDB(configureMongoDB: builder => builder.WithCamelCaseNamingPolicy()); ``` With camel case policy: ```csharp public class UserAccount { public string UserName { get; set; } // Stored as: "userName" public string EmailAddress { get; set; } // Stored as: "emailAddress" public DateTime CreatedDate { get; set; } // Stored as: "createdDate" } ``` ## Custom Naming Policies You can create your own naming policy by implementing `INamingPolicy`. The interface has two members: `GetPropertyName` transforms a member name, and `GetReadModelName` transforms a type into a collection name: ```csharp public class SnakeCaseNamingPolicy : INamingPolicy { public string GetPropertyName(string name) => ToSnakeCase(name); public string GetReadModelName(Type readModelType) => ToSnakeCase(readModelType.Name); static string ToSnakeCase(string name) { if (string.IsNullOrEmpty(name)) return name; var result = new StringBuilder(); for (int i = 0; i < name.Length; i++) { if (i > 0 && char.IsUpper(name[i])) result.Append('_'); result.Append(char.ToLower(name[i])); } return result.ToString(); } } ``` ### Registering Custom Policies Register your custom naming policy by passing an instance to `WithNamingPolicy`: ```csharp builder.UseCratisMongoDB(configureMongoDB: builder => builder.WithNamingPolicy(new SnakeCaseNamingPolicy())); ``` This also works when an application-defined policy requires constructor parameters. `CustomNamingPolicy` in this illustrative fragment is your own implementation, not an Arc type: ```csharp var namingPolicy = new CustomNamingPolicy(prefix: "app_", suffix: "_v1"); builder.UseCratisMongoDB(configureMongoDB: builder => builder.WithNamingPolicy(namingPolicy)); ``` ## How Naming Policies Work ### Convention Integration Naming policies are applied through the `NamingPolicyNameConvention`, which is automatically registered as a convention pack. This convention: 1. **Applies to all members**: Processes every property and field in your classes 2. **Uses configured policy**: Applies the naming policy you’ve configured 3. **Integrates with filtering**: Respects convention pack filters and ignore attributes ### Property Name Transformation The convention applies to: * **Public properties**: All public get/set properties * **Public fields**: Public field members (if configured) * **Nested objects**: Properties within embedded documents * **Collection elements**: Properties of objects within arrays ## Configuration Examples ### Multiple Policies You can create policies that combine transformations. `PrefixNamingPolicy` below is an application-defined implementation, not a built-in Arc policy. This example intentionally applies property transformations to the simple type name for collections; it does not compose each policy’s `GetReadModelName` behavior: ```csharp public class CompoundNamingPolicy : INamingPolicy { private readonly INamingPolicy[] _policies; public CompoundNamingPolicy(params INamingPolicy[] policies) { _policies = policies; } public string GetPropertyName(string name) => _policies.Aggregate(name, (current, policy) => policy.GetPropertyName(current)); public string GetReadModelName(Type readModelType) => _policies.Aggregate(readModelType.Name, (current, policy) => policy.GetPropertyName(current)); } // Usage var policy = new CompoundNamingPolicy( new CamelCaseNamingPolicy(), new PrefixNamingPolicy("data_") ); builder.UseCratisMongoDB(configureMongoDB: builder => builder.WithNamingPolicy(policy)); ``` ### Conditional Policies Create policies that apply different rules based on the property name: ```csharp public class ConditionalNamingPolicy : INamingPolicy { public string GetPropertyName(string name) { // Don't transform ID fields if (name.EndsWith("Id", StringComparison.OrdinalIgnoreCase)) return name.ToLower(); // Use camel case for everything else return char.ToLower(name[0]) + name[1..]; } public string GetReadModelName(Type readModelType) => char.ToLower(readModelType.Name[0]) + readModelType.Name[1..]; } ``` ## Convention Pack Integration The naming policy is implemented as a MongoDB convention pack, which means: ### Illustrative registration The namespace filter below illustrates an application-specific convention registration; `MyApp.Models` is **not** Arc’s automatic startup filter. Arc registers this convention using the configured convention-pack filters. Do not add this second registration after Arc initialization; customize Arc through the filtering mechanism below instead. ```csharp // Illustrative application filter, not Arc's automatic registration ConventionRegistry.Register( NamingPolicyNameConvention.ConventionName, new ConventionPack { new NamingPolicyNameConvention() }, type => type.Namespace?.StartsWith("MyApp.Models", StringComparison.Ordinal) == true ); ``` ### Filtering Support You can control which types the naming policy applies to using [convention pack filters](/arc/backend/csharp/mongodb/convention-packs/#filtering-conventions): ```csharp public class NoNamingPolicyForDTOs : ICanFilterMongoDBConventionPacksForType { public bool ShouldInclude(string conventionPackName, IConventionPack conventionPack, Type type) { if (conventionPackName == NamingPolicyNameConvention.ConventionName) { return !type.Name.EndsWith("DTO"); } return true; } } ``` ## Ignore Naming Conventions For specific types that shouldn’t use naming policies, use the `IgnoreConventions` attribute: ```csharp [IgnoreConventions(NamingPolicyNameConvention.ConventionName)] public class LegacyDocument { public string UserName { get; set; } // Stored as: "UserName" (unchanged) public string EmailAddr { get; set; } // Stored as: "EmailAddr" (unchanged) } ``` ## Impact on Queries Remember that naming policies affect how you write queries: ### With Camel Case Policy ```csharp // Property defined as: public string UserName { get; set; } // Stored in MongoDB as: "userName" // Query using the MongoDB field name var filter = Builders.Filter.Eq("userName", "john.doe"); // Or use expression trees (automatically converted) var users = await collection .Find(u => u.UserName == "john.doe") // Automatically converted to "userName" .ToListAsync(); ``` ## Error Handling ### Missing Policy Configuration A `DefaultNamingPolicy` is installed automatically. If custom builder code explicitly clears the policy, validation throws `NamingPolicyNotConfigured`: ```shell NamingPolicyNotConfigured: A naming policy for MongoDB has not been configured. Please configure it using the WithNamingPolicy method. ``` ### Null or Empty Names Naming policies should handle edge cases: ```csharp public string GetPropertyName(string name) { if (string.IsNullOrWhiteSpace(name)) return name; // Return unchanged for invalid input // Your transformation logic return TransformName(name); } ``` ## Best Practices ### Consistency Choose one naming convention and apply it consistently across your application: ```jsonc // Good: Consistent camelCase { "userId": "123", "userName": "john", "createdAt": "2024-01-15T10:30:00Z" } // Avoid: Mixed conventions { "userId": "123", "UserName": "john", "created_at": "2024-01-15T10:30:00Z" } ``` ### Consider External Systems If you’re integrating with external systems that expect specific naming conventions, align your policy accordingly: ```csharp // For systems expecting snake_case builder.WithNamingPolicy(new SnakeCaseNamingPolicy()); // For JavaScript/JSON APIs expecting camelCase builder.WithCamelCaseNamingPolicy(); ``` ### Performance Considerations Naming policies are called for every property during serialization setup, so keep them efficient: ```csharp // Good: Simple, efficient transformation public string GetPropertyName(string name) => char.ToLower(name[0]) + name[1..]; // Avoid: Complex regex or multiple string operations public string GetPropertyName(string name) => Regex.Replace(name, "([A-Z])", "_$1").ToLower().Trim('_'); ``` ## Next Steps * Learn about [Class Mapping](/arc/backend/csharp/mongodb/class-mapping/) for custom type configurations * Explore [Convention Packs](/arc/backend/csharp/mongodb/convention-packs/) for advanced customization * Understand [Concepts](/arc/backend/csharp/mongodb/concepts/) and how they work with naming policies # Observe MongoDB collections > Observe collections over the shared database change stream, with the actual options, lifetime, and failure contract. After ordinary collection reads work, use `Observe()` to publish an initial result and update it from MongoDB change streams. Every observation of a database shares one change stream, so observing many collections or queries does not open a stream of its own, or hold a MongoDB pool connection, for each subscription. This is a live result set, not an audit log or a guarantee that every intermediate state reaches every subscriber. ## Prerequisites * Complete [Arc and MongoDB setup](/arc/backend/csharp/mongodb/getting-started/), including Arc activation. Observation accesses Arc’s initialized service provider and query context. * Use a MongoDB replica set or sharded cluster supporting change streams, with read/change-stream permissions. A standalone server cannot supply the watch. * Map a document ID to a public CLR property; observation uses it to maintain membership. ## Choose an observation These are method fragments for a scoped service with an injected `IMongoCollection` named `collection`. Import `MongoDB.Driver`. ```csharp var all = collection.Observe(); var active = collection.Observe(author => author.IsActive); var filter = Builders.Filter.Eq(author => author.Category, "Fiction"); var fiction = collection.Observe(filter); var featured = collection.ObserveSingle(author => author.IsFeatured); var byId = collection.ObserveById(authorId); ``` The examples assume your `Author` has a Guid `Id` and the named properties. Collection overloads return `ISubject>`; single-document overloads return `ISubject`. When no document matches, single observation emits `null` and stays open; see [when there is no matching document](#when-the-observed-document-is-gone) below. The observation attaches to the shared stream when `Observe()` is called, not when the first subscriber arrives. The collection result replays its latest emission to late subscribers. It does not emit a placeholder empty collection before the initial database query finishes. ## How observations share a change stream Arc keeps one change stream per database per deployment per process, owned by the [`IMongoDBWatcher`](/arc/backend/csharp/mongodb/change-stream-watcher/) singleton. The stream is opened with the client the collection belongs to, so a collection from a client the application did not configure is watched where it lives. It watches the whole database, so it carries the changes of every collection. Each `Observe()`, `ObserveSingle()` and `ObserveById()` call subscribes to that stream and handles only the changes of its own collection, in order. You do not have to inject or opt in to anything: the extension methods use the shared stream automatically. ``` one change stream MongoDB database IMongoDBWatcher Observe Authors Observe Authors, filtered ObserveById Books ``` What this means in practice: * A board of 30 observable queries holds one change stream and one pooled connection for the change stream, not 30. * The stream is not narrowed by your filter, so the observation decides whether a changed document belongs. Inserts, updates and replaces cost one indexed lookup on the document’s own key; an observation that takes every document skips that lookup. Deletes cost nothing, because the observation already knows its members. * Paged observations re-read their page after any change in their collection. * When the stream reopens after a failure, or its collection is dropped or renamed, changes may have been missed. Every observation then reads its collection again and emits what changed. * Changes made while the initial query runs are held and applied after it, so none is lost. * A database that is not observed opens no stream. The stream for a database opens on the first observation and stays open for the life of the process. * In a multi-tenant application the stream is per resolved database name, so each tenant database gets its own. ## Find options, paging, and sorting `Observe` and `ObserveSingle` accept **nongeneric `FindOptions`**, not `FindOptions` or projection options: ```csharp var options = new FindOptions { MaxTime = TimeSpan.FromSeconds(10) }; var active = collection.Observe(author => author.IsActive, options); ``` This overload does not expose `Sort` or `Limit` properties through `FindOptions`. Collection observations take paging and sorting from the current query context. `ObserveSingle()` and `ObserveById()` always ignore that context’s paging and sorting and leave its `TotalItems` unchanged, even when `ignoreQueryContext: false` is passed; the parameter remains for compatibility. Use the [query paging contract](/arc/backend/csharp/queries/model-bound/paging/) when exposing a paged Arc query; arbitrary controller parameters do not populate that context automatically. ### Compose a primary observation with auxiliary sources When a query combines observations from several collections, client paging and sorting belong to the primary collection source. Pass `ignoreQueryContext: true` to each auxiliary **collection** observation to read **all** documents matching its filter without client paging or sorting. This also prevents that source from writing the query’s `TotalItems`, both at startup and on changes. Single-document observations already behave this way: ```csharp var primary = authors.Observe(); var auxiliary = categories.Observe(ignoreQueryContext: true); var featured = categories.ObserveSingle(category => category.IsFeatured); var selected = categories.ObserveById(categoryId); ``` This fragment assumes injected collections named `authors` and `categories`, a category with `IsFeatured`, and a matching `categoryId`. The option is available for expression and MongoDB filter overloads of `Observe`. The primary observation keeps its usual paging, sorting, and `TotalItems` behavior; **you** decide which source is primary. `TotalItems` describes the primary collection’s filtered count, not the size of an arbitrary result you derive by combining sources. If your composed result has different pagination semantics, compute its count explicitly rather than treating a collection’s count as the composed count. A single-result query reports `PagingInfo.NotPaged`, even if an inner collection observation writes a count to the ambient context. If you compose a collection observation inside such a query, pass `ignoreQueryContext: true` to that inner observation too; suppressing paging metadata does not stop the inner source from applying client paging. ## Lifetime and scope A directly owned subscription must be disposed. Example lifecycle fragment in an asynchronous method (`System.Reactive.Linq` supplies `Subscribe`): ```csharp var subject = collection.Observe(author => author.IsActive); using var subscription = subject.Subscribe( authors => Console.WriteLine($"Active authors: {authors.Count()}"), error => Console.Error.WriteLine($"Author observation failed: {error.Message}"), () => Console.WriteLine("Author observation completed")); await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); ``` When the last subscriber unsubscribes, the lifetime-aware subject detaches the observation from the shared stream. Cleanup completes and disposes the subject. The shared stream stays open for the other observations. Do not create an observation that is never subscribed or retained. Once completed, create a **new** observation rather than reusing the terminated subject. Arc registers collections as **scoped** services. Register a service that captures `IMongoCollection` as scoped too, not singleton. Keep its scope alive for the subscription’s lifetime. A background worker must establish the intended tenant context and resolve a collection inside an appropriate scope; do not cache the first tenant’s collection globally. See [MongoDB tenancy](/arc/backend/csharp/mongodb/tenancy/). ## Errors and recovery limits A failure to attach to the shared stream, to read from it, or to apply a change to the result, is logged at Error level (naming the collection and document type) and forwarded to subscribers through `OnError` before cleanup disposes the subject. This includes connection failures when MongoDB is unreachable. Normal cancellation, disposal, and completion of the shared stream (when the host shuts down) still complete quietly; unsubscribing does not produce an error. A failed observation is not resumed: a client resubscribes to get a fresh snapshot. Configuration errors before the background watch starts can still throw synchronously. Handle `OnError` to distinguish a failed watch from normal completion. Arc’s observable query hub forwards this failure to the frontend as a failed query result with `hasExceptions: true` and the error in `exceptionMessages`, rather than leaving it waiting for its first result. The shared stream itself reconnects with backoff and resumes from its last change, so a transient connection failure does not end your observations; see [the watcher’s lifetime](/arc/backend/csharp/mongodb/change-stream-watcher/#lifetime-and-disposal). An observation that has failed does not restart automatically. `.Retry(3)` on the existing subject does not recreate it. If your application needs reconnection, design and test ownership, fresh scopes/subjects, cancellation, and backoff explicitly; this page does not claim a tested automatic retry recipe. The [joined results](/arc/backend/csharp/mongodb/change-stream-watcher/) built with `IMongoDBWatcher.Observe()` use the same stream but have their own refresh and error contract. Do not transfer guarantees between the two APIs. ## Change handling and troubleshooting Insert, update, replace, and delete operations update observed membership. Updates that leave the filter must remove a document from the result; filters are not simply applied to every post-change document at the stream boundary. Do not assume automatic batching of rapid changes. Composite document IDs use the mapped ID member’s BSON serializer, just like ordinary document reads. Give a composite ID value equality, for example with a record, so change-stream IDs match the IDs already in the result. Updates, replacements, and deletions retain that identity; inserted change metadata also carries the typed ID. Guid, concept, and ObjectId IDs use their mapped serializers as well. ## When the Observed Document Is Gone `ObserveSingle()` and `ObserveById()` push a fresh document every time the underlying data changes, but there is no document to push when: * the observed document is deleted, * an update or replace moves the document out of the filter (it still exists, but no longer matches), or * the initial query never found a match in the first place. In every one of these cases the subject emits `default` — `null` for a reference type — rather than completing. Completing the observable would end the subscription; emitting `null` reports “nothing right now” while leaving the subscription open in case the document reappears, for example if it is re-inserted with the same id, or a later update moves it back into the filter. > **Note**: A `[ReadModel]` marked `[RemovedWith]` hard-deletes its backing document when the removal event fires. An active `ObserveSingle()`/`ObserveById()` subscriber sees that removal exactly as described above — “the read model was removed” is this same `null` emission, not a special case. On the frontend, guard against it the same way you guard against “still loading” — check `result.hasData` (and `result.isReady` if you need to distinguish “no result yet” from “ready, but nothing matches”): ```tsx const [result] = GetAccountObservable.use(accountId); if (!result.isReady) { return ; } if (!result.hasData) { return ; } return ; ``` ## Advanced Usage If no first result arrives, verify replica-set configuration, permissions, ID mapping, and connectivity. Enable logging for `MongoDB.Driver.MongoCollection` to inspect observation failures, and for `Cratis.Arc.MongoDB.MongoDBWatcher` to inspect the shared stream’s connection and reconnects. Test filtered membership, deletion, paging, and forced disconnections in your deployment before relying on live results. # BSON serializers > Understand Arc's BSON representations, precision limits, and serializer registration. Cratis Applications provides a comprehensive set of custom serializers for MongoDB to handle common .NET types that don’t have built-in MongoDB support or need special handling. ## Built-in Serializers The following serializers are registered by `WithMongoDB()` / `UseCratisMongoDB()`. The model and custom serializer snippets on this page are illustrative declarations/fragments for the [configured host](/arc/backend/csharp/mongodb/getting-started/), not independent programs. ### DateTimeOffset Support **Class**: `DateTimeOffsetSupportingBsonDateTimeSerializer` By default, writes BSON DateTime using `ToUnixTimeMilliseconds()` and reads with `DateTimeOffset.FromUnixTimeMilliseconds()`. This preserves the instant only to millisecond precision: **the original UTC offset and submillisecond ticks are lost**, and the restored value has offset zero. Store a separate offset/time-zone field if your domain needs it. ```csharp public class MyDocument { public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now; public DateTimeOffset UpdatedAt { get; set; } } ``` The serializer supports different BSON representations: * `BsonType.DateTime` (default) * `BsonType.String` `Int64` is not accepted as a representation, even though BSON DateTime internally holds milliseconds. Other representations throw. The current string format constant is `YYYY-MM-ddTHH:mm:ss.FFFFFFK`, not the standard round-trip `O` format. Uppercase `YYYY` is not a .NET year specifier, and only six fractional positions are included. Do not use this mode as a lossless offset/precision workaround; test legacy compatibility and use an application-owned serializer if a different storage contract is required. ### DateOnly Serializer **Class**: `DateOnlySerializer` Handles .NET 6+ `DateOnly` types, storing them efficiently in MongoDB: ```csharp public class EventRecord { public DateOnly EventDate { get; set; } public string Description { get; set; } } ``` ### TimeOnly Serializer **Class**: `TimeOnlySerializer` Handles .NET 6+ `TimeOnly` types for time-of-day values: ```csharp public class Schedule { public TimeOnly StartTime { get; set; } public TimeOnly EndTime { get; set; } } ``` ### TimeSpan Serializer **Class**: `TimeSpanSerializer` Handles serialization of `TimeSpan` values: ```csharp public class Task { public TimeSpan Duration { get; set; } public TimeSpan EstimatedTime { get; set; } } ``` ### Geospatial Serializers Cratis provides specialized serializers for geospatial types from `Cratis.Geospatial`. Their writers produce GeoJSON; query filters use the driver’s GeoJSON argument types. The current Polygon reader fails on the nested coordinate-pair arrays its writer emits: see the [Polygon read limitation](/arc/backend/csharp/mongodb/geospatial/polygon/#current-read-limitation) before relying on typed query materialization. For comprehensive documentation on storing and querying geographic data, see the [Geospatial Types](/arc/backend/csharp/mongodb/geospatial/) section, which covers: * **[Point](/arc/backend/csharp/mongodb/geospatial/point/)** — Single coordinates for locations and landmarks * **[LineString](/arc/backend/csharp/mongodb/geospatial/linestring/)** — Routes, paths, and trajectories * **[Polygon](/arc/backend/csharp/mongodb/geospatial/polygon/)** — Geographic areas and boundaries with optional exclusion zones ### Type Serializer **Class**: `TypeSerializer` Serializes `System.Type` instances, useful for polymorphic scenarios or when storing type information: ```csharp public class TypedDocument { public Type DocumentType { get; set; } public object Data { get; set; } } ``` ## Guid Configuration One of the most important default configurations is for `System.Guid`. MongoDB historically used a legacy GUID representation that could cause issues. Cratis Applications configures Guids to use the standard representation: ```csharp // This is done automatically during setup BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); ``` This is a description of Arc startup, **not an additional registration to paste after it**. BSON Guid values use Standard UUID binary representation. Existing legacy UUID data may need an explicit compatibility/migration strategy; changing registration does not rewrite stored values. ## Custom Serializers You can register additional serializers if needed: ### Using MongoDB’s Registration ```csharp BsonSerializer.RegisterSerializer(new MyCustomSerializer()); ``` ### Using Serialization Providers For more complex scenarios, implement `IBsonSerializationProvider`: ```csharp public class MySerializationProvider : IBsonSerializationProvider { public IBsonSerializer GetSerializer(Type type) { if (type == typeof(MyCustomType)) { return new MyCustomTypeSerializer(); } return null; } } // Register the provider BsonSerializer.RegisterSerializationProvider(new MySerializationProvider()); ``` ## Serializer Configuration Some serializers support configuration through interfaces. MongoDB’s serializer registry is process-wide and caches registrations; do not register a second global serializer for a type Arc has already registered. For a specific member, set a compatible serializer in its class map before the map freezes. ### Representation Configurable Serializers implementing `IRepresentationConfigurable` can be configured for different BSON representations: ```csharp // Representation selection only; see the string-format limitation above. var serializer = new DateTimeOffsetSupportingBsonDateTimeSerializer() .WithRepresentation(BsonType.String); ``` ## Polymorphic Serialization For complex inheritance hierarchies, the framework includes custom discriminator handling: ### Custom Object Discriminator Convention The `CustomObjectDiscriminatorConvention` provides better handling of polymorphic types by using more readable type strings instead of .NET’s default assembly-qualified names. ```csharp public abstract class BaseDocument { public string Id { get; set; } } public class TextDocument : BaseDocument { public string Content { get; set; } } public class ImageDocument : BaseDocument { public byte[] ImageData { get; set; } } ``` The discriminator will use simplified type names making the stored documents more readable and portable. ## Performance Considerations ### Serializer Caching MongoDB serializers are cached by type, so there’s no performance penalty for using custom serializers once they’re registered. ### Concept Serializers The [Concept serializers](/arc/backend/csharp/mongodb/concepts/) are optimized to serialize only the underlying value, not the wrapper object, providing efficient storage and retrieval. ## Error Handling Validation and null handling are serializer-specific; BSON serialization is not comprehensive domain or geometry validation. * **Geometry**: The current Point, LineString, and Polygon serializers dereference null values when writing and do not accept explicit BSON null when reading. Nullable-reference annotations do not add a null-aware serializer. * **Optional geometry**: Design and test an omission policy or a null-aware member serializer. For an ordinary nullable property with no initializer or required-member mapping, a missing field leaves it null. An omission setting such as `[BsonIgnoreIfNull]` skips writing that null member, but does **not** make reading an existing explicit BSON null safe. Test your actual class maps and stored data before adopting either policy. * **Concepts**: Nullable [concept serialization](/arc/backend/csharp/mongodb/concepts/#serialization-and-validation) is separately supported; `ConceptSerializer` explicitly writes and reads BSON null. * **Geometry validation**: Records and write paths do not validate geometry. The Polygon reader has ring-count/closure checks, but also the [current read limitation](/arc/backend/csharp/mongodb/geospatial/polygon/#current-read-limitation). ## Next Steps * Learn about [Concept serialization](/arc/backend/csharp/mongodb/concepts/) for domain-driven design * Explore [Class Mapping](/arc/backend/csharp/mongodb/class-mapping/) for custom type mapping * Configure [Naming Policies](/arc/backend/csharp/mongodb/naming-policies/) for consistent field naming # MongoDB tenancy > Preserve default database aliases and scope collections to the intended tenant. For tenancy concepts and tenant resolution, see the [tenancy overview](/arc/backend/csharp/tenancy/). To compare MongoDB with EF Core and Chronicle, see [tenancy end to end](/arc/backend/csharp/tenancy/end-to-end/). The default resolver returns the configured database for both `TenantId.NotSet` and `TenantId.Default` (`IsDefault`). For a named tenant it appends `+` to the configured database name. Collections and databases are scoped: resolve them after establishing the tenant context, and do not capture them in singleton services. Database naming is not authorization; tenant resolution and access checks must prevent unauthorized tenant selection. The example below deliberately uses a tenant prefix instead of the default suffix, while preserving both default aliases. Apply naming changes as a data migration decision, not an incidental refactor. ## Custom Database Resolvers You can create a custom database naming strategy by implementing a database name resolver and registering it with the MongoDB integration. ## Custom Resolver Example ```csharp using Cratis.Arc.MongoDB; using Cratis.Arc.Tenancy; using Microsoft.Extensions.Options; public class CustomMongoDatabaseNameResolver( ITenantIdAccessor tenantIdAccessor, IOptions options) : IMongoDatabaseNameResolver { public string Resolve() { var baseName = options.Value.Database; var tenantId = tenantIdAccessor.Current; return tenantId.IsDefault ? baseName : $"{tenantId.Value}_{baseName}"; } } ``` ## Registering the Resolver ```csharp builder.UseCratisMongoDB(configureMongoDB: mongodb => { mongodb.WithDatabaseResolver(); }); ``` ## When to Use a Custom Resolver A custom name resolver only changes the database name. To change the server or the credentials per tenant, use [connection resolution](/arc/backend/csharp/mongodb/connection-resolution/). * Match existing naming conventions. * Add environment or region prefixes. * Integrate with legacy database layouts. * Implement specialized isolation or sharding rules. # Observability > The tracing and metrics Arc emits, the activity source and meter to subscribe to, and the spans, attributes and metrics you will see. Arc instruments its own pipelines. Every command and query that runs produces activities on a named source and measurements on a named meter, so once you subscribe to them your tracing backend shows where time went inside Arc, and why an operation failed, without you adding anything to your handlers. This is emitted telemetry rather than an extension point. Arc registers the activity sources itself; what you do is subscribe to them. ## Set up OpenTelemetry with one call The quickest way to collect Arc’s telemetry is the shared `Cratis.OpenTelemetry` package, which is separate from `Cratis.Fundamentals`. Add the package to your host and call `AddCratisOpenTelemetry`: ```csharp using Cratis.OpenTelemetry; builder.AddCratisOpenTelemetry(options => options.ServiceName = "my-app"); ``` The call subscribes to every `Cratis.*` activity source and meter, so Arc’s `Cratis.Arc` source and meter are included alongside the Chronicle client. It also adds ASP.NET Core, HttpClient and runtime instrumentation. It registers an OTLP exporter only when an endpoint such as `OTEL_EXPORTER_OTLP_ENDPOINT` is configured. To add Cratis to an existing `AddOpenTelemetry()` call, use `services.AddOpenTelemetry().WithCratis()`. It brings its own exporter, so do not also call `UseOtlpExporter()`. If you own the whole pipeline, call `tracing.AddCratisInstrumentation()` and `metrics.AddCratisInstrumentation()` instead. The [OpenTelemetry setup guide](/fundamentals/csharp/opentelemetry/) covers configuration, resource identity and optional instrumentation. ## Subscribe to the source and the meter yourself Without the shared package, subscribe to Arc directly. Arc publishes its spans on an activity source named **`Cratis.Arc`** and its metrics on a meter of the same name. Both names are available as constants on `WellKnownDiagnostics`, so you do not have to repeat the string: ```csharp using Cratis.Arc; builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddSource(WellKnownDiagnostics.ActivitySourceName) .AddAspNetCoreInstrumentation()) .WithMetrics(metrics => metrics .AddMeter(WellKnownDiagnostics.MeterName) .AddAspNetCoreInstrumentation()); ``` Nothing else is required. The sources are registered when the host is built, so a subscriber either sees the spans or does not — there is no Arc-side switch to turn tracing on. ## The spans All of these are `Internal` activities. Each span keeps one stable name, which is what tracing backends show as the span name and what you filter and group on. The command or query it covers is an attribute, `cratis.arc.command.type` or `cratis.arc.query.name`, so a dashboard keyed on a span name keeps working as your application adds commands and queries. | Span | Raised when | | --- | --- | | `cratis.arc.command.execute` | a command runs through the pipeline | | `cratis.arc.command.validate` | a command is validated without being run | | `cratis.arc.command.filter` | the command filters run | | `cratis.arc.command.authorize` | an authorization filter decides on the command | | `cratis.arc.validator.invoke` | a validator runs, for a command, a query or a nested model | | `cratis.arc.command.provide` | a model-bound command’s `Provide()` method runs | | `cratis.arc.command.handle` | the command handler runs | | `cratis.arc.query.push` | a changed result of an observable query is pushed to a subscriber | | `cratis.arc.command.action` | a controller-based command action is invoked | | `cratis.arc.query.perform` | a query runs through the pipeline | | `cratis.arc.query.filter` | a query filter runs | | `cratis.arc.query.action` | a controller-based query action is invoked | | `cratis.arc.identity.resolve` | identity details are resolved for a request | The child spans nest inside the command span, which itself nests inside the request span your ASP.NET Core instrumentation already creates. For a `RegisterAuthor` command, the `execute`, `authorize`, `provide` and `handle` spans carry `cratis.arc.command.type = MyApp.Authors.RegisterAuthor`, and the validator span carries the validator type: ```text POST /api/authors/register └── cratis.arc.command.execute ├── cratis.arc.command.filter │ ├── cratis.arc.command.authorize │ └── cratis.arc.validator.invoke ├── cratis.arc.command.provide └── cratis.arc.command.handle ``` A validator runs once for every instance of the type it validates, so a command carrying a list of 500 items would otherwise add 500 spans. A validator gets a span the first time it runs in a validation, not for each item after that, and one validation adds at most 16 validator spans. For a request over HTTP, the command or query span starts before Arc prepares authorization, so a request turned away at that point still has a span and is still counted in the metrics. ### Attributes | Attribute | On | Value | | --- | --- | --- | | `cratis.arc.command.type` | `execute`, `validate`, `authorize`, `provide`, `handle` | the full name of the command type | | `cratis.arc.command.outcome` | `execute`, `validate` | `success`, `validation`, `authorization`, `append_rejected`, `cancelled` or `error` | | `cratis.arc.command.key.type` | `execute`, `validate` | the type the command declares its key as, such as its event source id with the Chronicle integration | | `cratis.arc.query.name` | `perform` | the fully qualified name of a known query; absent for a name that matches no query | | `cratis.arc.query.transport` | `perform` | `snapshot`, `observable`, or `unknown` when the query failed before it ran | | `cratis.arc.query.name` | `push` | the fully qualified name of a known query; `_other` for a name that matches no query | | `cratis.arc.query.transport` | `push` | `websocket` or `sse`, the transport the result is pushed over | | `cratis.arc.query.outcome` | `perform` | `success`, `validation`, `authorization`, `cancelled` or `error` | | `cratis.arc.validator.type` | `cratis.arc.validator.invoke` | the full name of the validator type | | `cratis.arc.validation.result_count` | `cratis.arc.validator.invoke` | how many results the validator produced | | `cratis.correlation_id` | `execute`, `validate`, `perform` | the correlation id of the operation | | `cratis.tenant` | `execute`, `validate`, `perform` | the tenant the operation runs for, when one was resolved while it ran; a query records it only when it was authorized for a principal | The `execute`, `validate`, `filter` and `perform` spans also keep the `command_type` and `query_name` attributes they carried in earlier versions. `query_name` is `_other` for a name that matches no query. Prefer the `cratis.arc.*` attributes for anything new. Arc records the tenant once the pipeline has established who the operation runs as, without resolving a tenant itself, since that would change what the operation sees. The tenant is held per asynchronous flow, so one that a filter or validator resolves inside its own call is not visible to the pipeline afterwards. An operation turned away before that point, for example a command with no handler, carries no tenant. ### Outcomes The outcome attribute says how an operation ended. Following OpenTelemetry, only an `error` outcome sets the span status to `Error`: a failure that is not a validation, authorization or event store rejection and not a cancellation. A command rejected by validation, by authorization or by the event store is the application working as intended, so its span status stays unset; filter on the outcome attribute to find those. `cancelled` means the operation’s cancellation token was cancelled, normally because the caller went away, and it leaves the status unset too. The child spans follow the same rule. An exception thrown from `Handle()`, `Provide()` or a validator adds an `exception` event to that span, but sets its status to `Error` only when it is an error: not when it is a validation failure (an exception implementing `IValidationFailure`, which Arc reports as a validation outcome) and not once the operation was cancelled. Depending on how an operation ended, events on its span say why: | Event | Raised when | Attributes | | --- | --- | --- | | `cratis.arc.validation.failed` | once for each validation result that blocked the operation, up to 16 | `cratis.arc.validation.severity`, `cratis.arc.validation.members`, `cratis.arc.validation.reason` and, when set, `cratis.arc.validation.reason_detail` | | `cratis.arc.authorization.denied` | authorization denied the operation, on the operation span and the `authorize` span | — | | `exception` | an exception was thrown, on the span it was thrown in and on the command or query span that turned it into a result | `exception.type` | `append_rejected` is the outcome when the rejection came from the event store rather than from a rule: a constraint violation or a concurrency conflict. The reason says which, and for a constraint the reason detail is the constraint name. ### What the spans never carry Spans carry types, names, identifiers, member names and outcomes. They never carry a value from a command, query or event, so a property marked `[PII]` or `[NotAudited]` cannot reach your tracing backend through them. That is why a validation event names the member and the rule but not the message, which often quotes the value, and why an exception event carries the exception type but not its message or stack trace; those stay in your logs. The event source id is recorded by its type, never by its value. ### Names as constants Every span, attribute, event and metric name in this page is a constant on `WellKnownTelemetryNames`, and every outcome is a constant on `WellKnownOperationOutcomes`, so dashboards, alerts and tests built in C# can use them instead of repeating the strings. ## What this is useful for The split between `validate` and `execute` is the one worth watching. Validation that reaches a database — a uniqueness rule, a state-dependent check — is easy to write and easy to forget, and it shows up here as time spent before the handler ever ran. `cratis.arc.identity.resolve` is the other common surprise. The span covers each identity lookup, such as the frontend’s `/.cratis/me` request. Every lookup calls your identity-details provider, so one that queries a store puts that query on the critical path of every lookup. ## Metrics Arc records these on the `Cratis.Arc` meter: | Metric | Type | Unit | Attributes | | --- | --- | --- | --- | | `cratis.arc.command.duration` | histogram | `s` | `cratis.arc.command.type`, `cratis.arc.command.outcome` | | `cratis.arc.command.outcomes` | counter | `{command}` | `cratis.arc.command.type`, `cratis.arc.command.outcome` | | `cratis.arc.query.duration` | histogram | `s` | `cratis.arc.query.name`, `cratis.arc.query.transport`, `cratis.arc.query.outcome` | | `cratis.arc.query.subscriptions` | up-down counter | `{subscription}` | `cratis.arc.query.name`, `cratis.arc.query.transport` | | `cratis.arc.query.pushes` | counter | `{push}` | `cratis.arc.query.name`, `cratis.arc.query.transport` | A command validated without being run is traced but not counted. For an observable query the duration covers setting up the subscription, not how long it stays open. The subscriptions metric goes up when a subscription is established and down exactly once when it ends, whichever way it ends, so its sum is the number of subscriptions open now. The pushes metric counts results the query’s stream emitted; a snapshot or a validation rejection that answers the subscribe is not counted. A push span is a child of the activity that was current when the subscription was established, and links to the activity that caused the change when one is current. The pushes and subscriptions metrics use the `websocket` and `sse` transports, where the duration histogram reports `observable`. Observable queries answered over a single HTTP request are not counted. Each metric records at most 1,000 distinct command types or query names, the subscription and push metrics included. The limit is fixed; there is no option to change it. Past that, further ones are recorded as `_other`, so a metric backend never has to hold an unbounded number of series. The limit holds exactly, however many requests arrive at once; only right at the limit can a new name be recorded as `_other` once before it gets the last free place. A query name that matches no known query is always recorded as `_other`. When nothing listens to the meter or the activity source, Arc skips most of the work of recording: it starts no child spans, builds no attributes or events, and does not look up whether a query name is known. It still checks whether anything listens on every operation. The MongoDB integration records its client metrics on the same meter. ## On the JVM The Kotlin and Java backend observes the same pipeline stages but through Micrometer rather than `ActivitySource`, with its own names and an explicit correlation attribute. See [Observability](/arc/backend/kotlin/guides/observability/) for that side. The two are not wire-compatible with each other and are not meant to be — each uses what its ecosystem already collects. # Microsoft.AspNetCore.OpenApi > Add Arc's schema and operation transformers to Microsoft.AspNetCore.OpenApi documents with Cratis.Arc.OpenApi, and know their limits. The `Cratis.Arc.OpenApi` package provides deep integration with `Microsoft.AspNetCore.OpenApi` (.NET 10+), adding schema and operation transformers for Arc conventions. Review the enum and model-bound response limitations below before treating generated schemas as a wire-contract guarantee. This is the ASP.NET host integration, not [Core’s built-in lightweight OpenAPI](/arc/backend/csharp/core/openapi/). ## Overview | Topic | Description | | --- | --- | | [Concepts](/arc/backend/csharp/open-api/concepts/) | How concept types are mapped to their underlying primitive types in the API schema. | | [Commands](/arc/backend/csharp/open-api/commands/) | How command responses are wrapped with `CommandResult` in the API documentation. | | [Queries](/arc/backend/csharp/open-api/queries/) | How query responses are wrapped with `QueryResult`, including pagination parameters. | | [Enums](/arc/backend/csharp/open-api/enums/) | Enum schemas follow the effective JSON converter’s numeric or string wire values. | | [FromRequest Attribute](/arc/backend/csharp/open-api/from-request/) | How complex model binding with `[FromRequest]` is reflected in the API schema. | | [Model-Bound Operations](/arc/backend/csharp/open-api/model-bound/) | How minimal API command and query endpoints appear in the API documentation. | ## Setup Add the `Cratis.Arc.OpenApi` NuGet package to your project and call `AddConcepts()` inside your `AddOpenApi` configuration: These are **configuration fragments** for an existing ASP.NET Core Arc host. Import `Cratis.Arc.OpenApi` and `Microsoft.Extensions.DependencyInjection`, then register before `builder.Build()`: ```csharp builder.Services.AddOpenApi(options => options.AddConcepts()); ``` After building the app, map the document using ASP.NET’s `app.MapOpenApi()` (default `/openapi/v1.json`). Keep `app.UseCratisArc()` and the host’s normal startup too. Map only in intended environments or apply appropriate endpoint access controls; API documentation is not automatically private. The `AddConcepts()` method registers the schema and operation transformers. By default, concept values and collections use primitive schemas, and recursive values in dictionaries keyed by concepts use component references. A custom concept converter can change the wire shape; OpenAPI leaves its schema unspecified unless you provide a matching schema transformer. The other transformers describe **effective** converters: date, time, URI and type strings, GeoJSON objects, and polymorphic base properties are handled when an application explicitly configures the corresponding Arc converter on its HTTP JSON options. `AddCratisArc()` does not install those non-concept converters on plain minimal APIs. Without them, ASP.NET’s defaults determine the wire format and schema. With Fundamentals’ derived-type converter explicitly enabled, the base schema lists declared interface properties by camel-cased CLR name. A plain minimal API `Results.Ok(derived)` response uses the runtime concrete type: it includes derived-only properties but **no** `_derivedTypeId` discriminator. The base schema does not list the derived-only properties. Serialization explicitly through the declared interface with Fundamentals’ converter can include `_derivedTypeId`; verify the actual endpoint contract before generating clients. `[JsonPropertyName]` and `[JsonIgnore]` on a base interface do not affect this converter’s concrete property writes. Avoid indexers on derived types: the converter attempts to read them without arguments and cannot serialize them. ## Requirements The `Cratis.Arc.OpenApi` package targets `.NET 10` and later. The transformer API (`IOpenApiSchemaTransformer`, `IOpenApiOperationTransformer`) and the underlying `Microsoft.OpenApi` 2.x schema types are only available from .NET 10 onwards. ## Relationship to Swagger Arc also ships a separate `Cratis.Arc.Swagger` package for Swashbuckle-based Swagger UI. Both packages cover the same set of Arc features, but use different APIs: | | `Cratis.Arc.Swagger` | `Cratis.Arc.OpenApi` | | --- | --- | --- | | Framework | Swashbuckle (`ISchemaFilter`, `IOperationFilter`) | `Microsoft.AspNetCore.OpenApi` (`IOpenApiSchemaTransformer`, `IOpenApiOperationTransformer`) | | Host | ASP.NET Core | ASP.NET Core | | Registration | `services.AddSwaggerGen(o => o.AddConcepts())` | `services.AddOpenApi(o => o.AddConcepts())` | Both are fully independent; choose the one that matches your toolchain. # Commands > How the OpenAPI command transformer describes Arc's CommandResult envelope for controller commands. Arc wraps participating controller commands in a `CommandResult` or `CommandResult` envelope. This is not a guarantee about every arbitrary mutation endpoint in an ASP.NET application. The `CommandResultOperationTransformer` automatically updates the generated operation documentation to reflect this. ## What the transformer does For every endpoint whose controller action or method is identified as a command (not marked with `[AspNetResult]`), the transformer: 1. Replaces the 200 response schema with `CommandResult` (for `void`/`Task` returns) or `CommandResult` (for typed returns). 2. Adds standard error response schemas for 400, 403, and 500 status codes — all using the same `CommandResult`/`CommandResult` schema so clients only need to handle one type. ## Response status codes | Status code | Meaning | | --- | --- | | 200 | Command executed successfully | | 400 | Validation error or malformed payload | | 403 | Forbidden — insufficient permissions | | 500 | Unexpected server error | ## Concept return types If the command returns a concept (a type inheriting from `ConceptAs`), the transformer unwraps the concept to its underlying primitive type before generating the `CommandResult` schema. The following **type/action fragments** illustrate a response contract; place the action inside an existing MVC controller and supply the application service: ```csharp public record InvoiceId(Guid Value) : Cratis.Concepts.ConceptAs(Value); ``` ```csharp [HttpPost] public Task CreateInvoice(CreateInvoice command, [FromServices] IInvoiceService invoices) => invoices.Create(command); ``` The transformer constructs `CommandResult`; its response member is represented in JSON Schema as a string with UUID format. It does not change a runtime `Guid` response into a different command contract. ## Opting out Decorate the action with `[AspNetResult]` to bypass the transformer and expose the raw return type directly: ```csharp [HttpPost] [AspNetResult] public Task CreateInvoice(CreateInvoice command, [FromServices] IInvoiceService invoices) => invoices.Create(command); ``` # Concepts > How the OpenAPI concept transformer describes ConceptAs types as their underlying JSON primitives. Arc uses [concept types](/fundamentals/csharp/concepts/) as strongly-typed wrappers around primitives such as `Guid`, `string`, or `int`. Without special handling, the generated API schema would expose these as complex objects with a single `Value` property—which is rarely what API consumers expect. The `ConceptSchemaTransformer` detects any schema type that inherits from `ConceptAs` and replaces the schema with the equivalent JSON primitive type of the underlying value. ## Example ```csharp using Cratis.Concepts; public record CustomerId(Guid Value) : ConceptAs(Value); ``` Without the transformer the schema for `CustomerId` would be: ```json { "type": "object", "properties": { "value": { "type": "string", "format": "uuid" } } } ``` With the transformer the schema becomes: ```json { "type": "string", "format": "uuid" } ``` This applies wherever the concept type appears — as a request parameter, in a request body, or in a response schema. ## Supported Underlying Types The transformer maps any primitive or common .NET type that `ConceptAs` supports: | .NET type | JSON Schema type | Format | | --- | --- | --- | | `Guid` | `string` | `uuid` | | `string` | `string` | — | | `int` / `long` | `integer` | `int32` / `int64` | | `float` / `double` | `number` | `float` / `double` | | `bool` | `boolean` | — | | `DateTimeOffset` / `DateTime` | `string` | `date-time` | # Enums > Match OpenAPI enum schemas to the effective JSON converter's wire values. Arc serializes enums numerically by default. `EnumSchemaTransformer` describes the values written by the effective JSON converter, so the OpenAPI schema agrees with the wire format. ## Example ```csharp public enum InvoiceStatus { Draft, Sent, Paid, Overdue } ``` With ASP.NET Core’s default numeric serialization for plain minimal APIs (and Arc’s own default numeric serialization for its endpoints), the schema is: ```json { "type": "integer", "enum": [0, 1, 2, 3] } ``` If the effective HTTP JSON options use a `JsonStringEnumConverter` instead, the schema uses `"type": "string"` and `"enum": ["Draft", "Sent", "Paid", "Overdue"]`. Verify both the response and generated schema after changing converters. ## Configuration boundary Arc-generated endpoints serialize with `ArcOptions.JsonSerializerOptions`. ASP.NET’s `ConfigureHttpJsonOptions` configures plain minimal API endpoints, not Arc-generated endpoints. Plain minimal APIs retain ASP.NET Core’s enum serialization by default, including long-backed values greater than `int.MaxValue`; Arc does not append its enum converter there. A `JsonStringEnumConverter` registered with `ConfigureHttpJsonOptions` writes strings for plain minimal APIs and changes their schema to string values. The first matching application converter wins. The OpenAPI transformer uses the JSON options for the schema’s endpoint; keep them aligned with the endpoint’s actual response. ## See also * [JSON serialization configuration](/arc/backend/csharp/asp-net-core/configuration/#json-serialization) — choose the correct options target. * [OpenAPI setup](/arc/backend/csharp/open-api/) — ASP.NET transformer registration. * [Lightweight OpenAPI](/arc/backend/csharp/core/openapi/) — a different, metadata-only implementation. # FromRequest Attribute > How the OpenAPI transformer documents [FromRequest] models as a single request body instead of separate parameters. The `[FromRequest]` attribute lets you combine data from multiple parts of an HTTP request — body, route, and query string — into a single model object. See [FromRequest Attribute](/arc/backend/csharp/asp-net-core/from-request/) for full details on how model binding works. ## OpenAPI documentation Without special handling, parameters decorated with `[FromRequest]` would appear as individual query/route parameters in the API documentation even though they are bound as a single body object at runtime. The `FromRequestOperationTransformer` and `FromRequestSchemaTransformer` correct this. ### What the transformers do 1. **`FromRequestOperationTransformer`** — For each parameter marked `[FromRequest]`: * Removes the parameter from the `parameters` list of the operation. * Creates a `requestBody` entry using the parameter’s type schema. 2. **`FromRequestSchemaTransformer`** — Ensures the schema for the request body accurately reflects the model’s properties, excluding any properties that come from route or query binding. The schema transformation does not change the runtime merge rule: body values win unless they equal CLR `default(T)`. Removing a property from a schema does not enforce that the value came from the URL. See [binding defaults and precedence](/arc/backend/csharp/asp-net-core/from-request/). ## Example These are illustrative request/action fragments for an existing MVC controller with application-owned customer types and service: ```csharp public record UpdateCustomerRequest( [property: FromRoute] CustomerId CustomerId, string Name, string Email); [HttpPut("{customerId}")] public Task UpdateCustomer([FromRequest] UpdateCustomerRequest request, [FromServices] ICustomerService customers) => customers.Update(request); ``` The intended operation contract is: * **Path parameter**: `customerId` (from the route) * **Request body**: a JSON schema with `name` and `email` properties The operation transformer replaces the matching request parameter with a body; the schema transformer removes route/query-decorated properties. It does not itself create missing path-parameter declarations, so verify the final document supplied by ASP.NET API Explorer rather than assuming these transformers guarantee the complete operation shape. # Model-Bound Operations > How Arc's transformers describe model-bound command and query endpoints in Microsoft.AspNetCore.OpenApi documents. Arc supports minimal API-style endpoints for commands and queries, called *model-bound operations*. These endpoints are registered automatically by the Arc infrastructure and follow a convention-based naming scheme (`Execute`). Because model-bound endpoints do not use traditional controller actions, the standard `CommandResultOperationTransformer` and `QueryResultOperationTransformer` transformers do not apply to them. The `ModelBound.CommandOperationTransformer` and `ModelBound.QueryOperationTransformer` fill this gap. ## Registration Model-bound transformers are included automatically when you call `AddConcepts()`: ```csharp builder.Services.AddOpenApi(options => options.AddConcepts()); ``` They can also be registered independently: ```csharp builder.Services.AddOpenApi(options => options.AddModelBoundOperationTransformers()); ``` ## Command operations The `ModelBound.CommandOperationTransformer` matches operations whose `operationId` starts with `Execute` and resolves the command type from the registered `ICommandHandlerProviders`. For matched operations it: 1. Sets the `requestBody` to a schema of the command type. 2. Attempts to infer a `CommandResult` / `CommandResult` 200 response schema from the registered handler adapter’s `Handle` method. 3. Adds 400, 403, and 500 error response schemas. Caution The current transformer inspects the adapter type, not the command record’s actual `Handle()` return contract. The standard `ModelBoundCommandHandler` exposes `ValueTask`; inference does not recover your concrete response payload from that adapter. Do not assume the generated schema describes a returned DTO, `Guid`, or typed result correctly. Runtime command response behavior is unchanged; verify the schema against an actual response before client generation. Command type illustration (application persistence is deliberately outside this example): ```csharp using Cratis.Arc.Commands.ModelBound; [Command] public record EchoInvoiceId(Guid Id) { public Guid Handle() => Id; } ``` ## Query operations The `ModelBound.QueryOperationTransformer` attempts to match operations whose `operationId` starts with `Execute` against registered `IQueryPerformerProviders`. Current limitation: it compares the suffix to `performer.Name`, while the runtime GET mapper uses `performer.FullyQualifiedName` in endpoint names. A normal model-bound method name and its fully qualified name differ, so this transformer can miss the generated operation. The additional HTTP `QUERY` endpoint is excluded from API description by its request reader. Inspect the generated document rather than assuming all query arguments and result schemas below were applied. For matched operations it: 1. Adds each query parameter from the performer’s parameter list as a query string parameter, currently with `Required = false` even when runtime binding requires it. 2. Adds paging and sorting parameters when `IQueryPerformer.SupportsPaging` is `true`. 3. Sets the 200 response schema to `QueryResult`. 4. Adds 400, 403, and 500 error response schemas. ## Pagination and sorting parameters For query performers that support paging, the following query parameters are added: | Parameter | Type | Description | | --- | --- | --- | | `sortby` | `string` | Field name to sort by | | `sortDirection` | `string` (`asc` \| `desc`) | Sort direction | | `pageSize` | `integer` | Number of items per page | | `page` | `integer` | Page number (0-based) | # Queries > How the OpenAPI query transformer describes Arc's QueryResult envelope, paging, and sorting for controller queries. Arc queries return results wrapped in a `QueryResult` envelope that carries the data along with paging metadata and error information. The `QueryResultOperationTransformer` ensures this is accurately reflected in the generated API documentation. ## What the transformer does For every endpoint whose method is identified as a query (not marked with `[AspNetResult]`), the transformer: 1. Replaces the 200 response schema with `QueryResult`. 2. Adds standard error response schemas for 400, 403, and 500 status codes. 3. Appends paging and sorting query parameters when the method’s declared return type passes its enumerable check. Do not assume a task-wrapped collection has identical inference. Model-bound queries use [a separate transformer](/arc/backend/csharp/open-api/model-bound/). ## Pagination and sorting parameters When a query endpoint returns an enumerable result, the following query parameters are added automatically: | Parameter | Type | Description | | --- | --- | --- | | `sortby` | `string` | Field name to sort by | | `sortDirection` | `string` (`asc` \| `desc`) | Sort direction | | `pageSize` | `integer` | Number of items per page | | `page` | `integer` | Page number (0-based) | ## Response status codes | Status code | Meaning | | --- | --- | | 200 | Query executed successfully | | 400 | Invalid request or parameters | | 403 | Forbidden — insufficient permissions | | 500 | Unexpected server error | ## Opting out Decorate the action with `[AspNetResult]` to bypass the transformer and expose the raw return type directly. This **controller action fragment** assumes application-owned `Invoice` and `IInvoiceRepository` types: ```csharp [HttpGet] [AspNetResult] public IEnumerable GetInvoices([FromServices] IInvoiceRepository invoices) => invoices.All(); ``` # Backend hosting overview > Choose ASP.NET Core or the lightweight Arc.Core host, then configure persistence and proxy generation separately. You want one command/query model without accidentally choosing an event store or the wrong web host. Arc separates those decisions: **Arc.Core provides the application model**, and **Cratis.Arc adds ASP.NET Core integration**. Neither requires Chronicle. ## Choose the host first | Host | Package and bootstrap | Use it when… | | --- | --- | --- | | ASP.NET Core | `Cratis.Arc`, `WebApplication.CreateBuilder(args)`, `builder.AddCratisArc()`, `app.UseCratisArc()` | You want ASP.NET Core routing, middleware, controllers alongside model-bound endpoints, and its authentication ecosystem | | Lightweight host | `Cratis.Arc.Core`, `ArcApplication.CreateBuilder(args)` | You intentionally want Arc’s generic-host / HttpListener-based endpoint surface without ASP.NET Core | The [standalone getting-started path](/arc/backend/csharp/getting-started/) uses ASP.NET Core throughout. The [Arc.Core getting-started guide](/arc/backend/csharp/core/getting-started/) explains the alternative. Do not copy one host’s extension-method sequence into the other. ``` depends on uses chooses or chooses Cratis.Arc ASP.NET Core integration Arc.Core application model ArcApplication lightweight host Application ``` Arc.Core is useful when you do not need ASP.NET Core, but that is not a guarantee of compatibility with every device, desktop, browser, AOT, or restricted runtime. Validate the selected host and dependencies on your deployment target. ## Then choose persistence and client output Commands may call application services, write MongoDB collections, or use EF Core contexts. Add [Chronicle](/arc/backend/csharp/chronicle/) explicitly when the slice needs event persistence, projections, or reactors. Returning an event-shaped object from standalone Core is ordinary response handling, not an automatic append. Separate decisions from inline persistence Direct service and database calls remain supported. When a model-bound command can describe its writes from the available inputs, prefer a pure `Handle()` returning [command operations](/arc/backend/csharp/commands/operations/). Chronicle commands should still return events for durable facts; operations do not replace queries or `Provide()` reads. [Proxy generation](/arc/backend/csharp/proxy-generation/getting-started/) is a separate post-build tool. Install its build package and configure an output path; it inspects compiled assemblies and PDB source information rather than querying your running endpoints. Generate in Debug before type-checking the frontend. ## Continue * [Set up standalone Arc](/arc/backend/csharp/getting-started/) — packages, imports, database configuration, and the first runnable checkpoint. * [ASP.NET Core integration](/arc/backend/csharp/asp-net-core/) — middleware, authentication, and host-specific behavior. * [Arc.Core overview](/arc/backend/csharp/core/overview/) — the lightweight surface and its limits. * [Backend guide](/arc/backend/csharp/) — commands, queries, providers, identity, and tenancy. # Proxy generation > Generate typed TypeScript clients from a compiled Arc application. Keeping frontend request types in sync with backend endpoints is repetitive work. Arc’s proxy generator turns your compiled commands, queries, and models into TypeScript clients, including React hooks and serialization metadata. This is a **standalone Arc capability**. It does not require Chronicle or event sourcing; commands may call application services and return ordinary responses. ## How it works The `CratisProxyGenerator` MSBuild target runs **after `AfterBuild`**, when `CratisProxiesOutputPath` is nonempty. It launches a .NET executable over your compiled assembly. It is **not a Roslyn source generator** running inside the C# compilation. ``` C# endpoints and types Compile assembly Post-build proxy executable Portable PDB symbols TypeScript proxies and models Frontend compilation ``` The executable discovers controller-based and model-bound endpoints, examines request and response types, extracts supported validation rules, and writes clients. PDB symbols optionally help choose output filenames; namespaces determine folders. ## What you receive * **Commands**: classes with typed properties, `execute()`, and React `use()` hooks. * **Queries**: one-shot or observable clients, parameter interfaces when needed, and React hooks. * **Models**: classes with runtime serialization metadata by default. * **Enums**: numeric TypeScript enums, including an all-flags constant for `[Flags]` enums. * **Identity details**: types discovered through `IProvideIdentityDetails`. * **Barrel files**: `index.ts` exports, unless generation is disabled. There is no generated `Bindings` artifact or generated `Bindings.initialize()` call. Configure React with the [`Arc` provider](/arc/frontend/react/arc/). ## Choose your next step 1. [Set up generation](/arc/backend/csharp/proxy-generation/getting-started/), including a safe output location and frontend dependencies. 2. Inspect [command](/arc/backend/csharp/proxy-generation/commands/), [query](/arc/backend/csharp/proxy-generation/queries/), and [type mapping](/arc/backend/csharp/proxy-generation/type-mapping/) contracts. 3. Adjust [configuration](/arc/backend/csharp/proxy-generation/configuration/) when your folder structure or routes differ from the defaults. 4. Read [output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) before mixing generated and handwritten files. Cleanup is not a general-purpose source-preservation mechanism. For specialized output, see [identity details](/arc/backend/csharp/proxy-generation/identity-details/), [validation extraction](/arc/backend/csharp/proxy-generation/validation/), [file tracking](/arc/backend/csharp/proxy-generation/file-index-tracking/), and [linting generated proxies](/arc/backend/csharp/proxy-generation/linting/). # Command proxy generation > Discover command endpoints and generate typed execution clients. The proxy generator creates TypeScript command classes that provide type-safe command execution with React hook integration. ## Supported approaches Commands can be implemented using two approaches, both of which are supported by the proxy generator: * **Controller-based**: Commands in ASP.NET Core controllers using `[HttpPost]` attributes * **Model-bound**: Simplified approach where a type represents the command directly For detailed information on implementing commands, see the [Commands documentation](/arc/backend/csharp/commands/). ## How commands are discovered ### Controller-based commands The generator discovers controller-based commands by looking for: * Methods marked with `[HttpPost]` * Parameters marked with `[FromBody]`, `[FromRoute]`, or `[FromQuery]` See [Controller-based Commands](/arc/backend/csharp/commands/controller-based/) for implementation details. ### Model-bound commands The generator discovers model-bound commands by finding types that: * Are decorated with the `[Command]` attribute * Have a `Handle()` method (the command handler) The type name becomes the command name. Public instance properties declared directly on the command type become command properties in the generated TypeScript; this pass does not collect inherited properties. See [Model-bound Commands](/arc/backend/csharp/commands/model-bound/) for implementation details. ## Generated command structure Generated command classes: * Extend the `Command` base class from `@cratis/arc/commands` * Include all properties from route parameters, query parameters, and body content * Provide a static `use()` method for React hook integration * Include the proper route based on the configuration ## Generated artifacts For each command, the generator creates: 1. **Interface**: An `ICommandName` interface with all command properties 2. **Class**: A `CommandName` class extending `Command`, or `Command` for a response-bearing command 3. **Route**: The HTTP route derived from the controller route or model-bound configuration ## Model-bound Chronicle events stay on the server For model-bound commands, the Chronicle integration declares `[EventType]` values as server-handled, so the generator excludes them from the typed client response. It recognizes inherited attributes when the attribute permits inheritance, and typed reference-element event collections compatible with `IEnumerable`, such as `IEnumerable`, `ProjectRegistered[]`, and `List`. Rectangular arrays and collections of attributed structs do not match that enumerable declaration and remain responses. This is a static declaration of supported return shapes, not a guarantee for arbitrary runtime values. Chronicle’s `CanHandle` still requires registered event types and event-source routing; event collections also require non-null registered elements. Keep those runtime requirements aligned with the declared command signature. An event-only command produces `Command` with no response payload. Returning `(ProjectRegistered, ProjectSummary)` produces `Command`; returning an identifier alongside an event, an `EventForEventSourceId` wrapper, or command operations exposes only the identifier. Return a typed event collection or use `EventForEventSourceId` for explicit event-source targeting. An `IEnumerable` signature cannot prove that its elements are events, so the generator conservatively keeps it as a response collection. Chronicle may consume those values at runtime; do not rely on that broad signature to describe an event-only client contract. Controller actions do not run response value handlers: Arc places the action’s returned value directly in `CommandResult.Response`. The generator therefore retains controller response types even when a handler declares them as server-handled, including `[EventType]` values and typed event collections. Server-handled declarations also do not remove candidates from controller tuple or union response selection. Integration authors can declare attributed server-handled values for model-bound commands through the [response value handler marker](/arc/backend/csharp/commands/response-value-handlers/#declaring-attributed-values). ## Command operations stay on the server [Command operations](/arc/backend/csharp/commands/operations/) declare inline work rather than response DTOs. For model-bound commands, the generator excludes `ICommandOperation` implementations and `CommandOperations` batches from the caller’s response, including supported tuple and result alternatives. A tuple containing a response and operations produces a response-bearing proxy for the response alone; an operation-only command has no response payload. No operation execution or compensation code is generated for the frontend. Backend recovery observations are also excluded from HTTP JSON. Clients continue to use the existing `CommandResult` contract. Operation method invokers are generated by Arc’s C# source generator. TypeScript proxies are still produced by the separate [post-build proxy generator](/arc/backend/csharp/proxy-generation/getting-started/); these are different generation steps. ## Excluding commands from generation To exclude specific controller-based commands from proxy generation, mark them with the `[AspNetResult]` attribute. This is useful when you want to handle the response manually or when the command returns a non-standard result. ## Route configuration For **conventional model-bound commands**, the generated route is affected by the `CratisProxiesSkipCommandNameInRoute` configuration option. Controller routes come from their ASP.NET declarations, not this switch: * When `false` (default): The command type name is included in the route * When `true`: The command type name is excluded from the route **Automatic Conflict Detection**: When `CratisProxiesSkipCommandNameInRoute` is `true`, the proxy generator automatically detects if multiple commands exist in the same namespace (after skipping segments). If a conflict is detected, the command name is automatically included in the route to prevent route collisions. Align the generator’s settings with the runtime endpoint settings; changing the build configuration does not configure the server. For example: * Single command in namespace: Route is clean without type name (e.g., `/api/orders`) * Multiple commands in same namespace: Type names are added automatically (e.g., `/api/orders/create-order`, `/api/orders/update-order`) The examples assume the application namespace prefix has been skipped. See [routing configuration](/arc/backend/csharp/proxy-generation/configuration/routing/) for segment counts, defaults, and runtime alignment. ## Frontend usage The generated command proxies integrate with React through the `use()` static method, which returns: 1. The command instance with all properties. 2. A `SetCommandValues` setter for editing values. 3. A `ClearCommandValues` function. The signature is `use(initialValues?: ICommandName): [CommandName, SetCommandValues, ClearCommandValues]`. This is an API shape, not a manually authored replacement for the generated class. The setter edits values; it does not establish a new change-tracking baseline. Await `execute()` to receive a `CommandResult` with success/failure information, `validationResults`, and the typed response when applicable. For frontend usage patterns, see [React commands](/arc/frontend/react/commands/react-usage/). Commands can return ordinary application responses, including `Guid`; proxy generation does not require event sourcing. # Proxy generator configuration > MSBuild properties, mapping items, defaults, and CLI differences. Configure generation through properties and items **inside the existing `.csproj`’s `Project` element**. The examples in these reference pages are project fragments, not standalone project files. Item `Include` values supply MSBuild identities; the generator consumes each item’s named metadata. ## Choose a topic * [Basic options](/arc/backend/csharp/proxy-generation/configuration/basic/): output path, namespace segments, source-file grouping, decorators. * [Library mode](/arc/backend/csharp/proxy-generation/configuration/library-mode/): collect models beyond endpoint references; separate CLI interface output. * [Emit interfaces](/arc/backend/csharp/proxy-generation/configuration/emit-interfaces/): plain interfaces with no decorators and no runtime dependency. * [Proxy file suffix](/arc/backend/csharp/proxy-generation/configuration/proxy-file-suffix/): name generated files `Name.proxy.ts` to tell them apart from hand-written TypeScript. * [Type exclusions](/arc/backend/csharp/proxy-generation/configuration/type-exclusions/): omit types or namespace patterns. * [Namespace roots](/arc/backend/csharp/proxy-generation/configuration/namespace-roots/): remap output folders without changing API routes. * [Assembly-to-package mappings](/arc/backend/csharp/proxy-generation/configuration/assembly-package-mappings/): import shared model packages. * [Type mapping](/arc/backend/csharp/proxy-generation/type-mapping/): primitive mappings and `TypeToTsType` overrides. * [Routing](/arc/backend/csharp/proxy-generation/configuration/routing/): conventional model-bound route options and runtime alignment. * [Output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/): incremental writes, full deletion, and cleanup limitations. ## MSBuild reference | Property or item | Default / required metadata | Topic | | --- | --- | --- | | `CratisProxiesOutputPath` | Empty disables generation | [Basic](/arc/backend/csharp/proxy-generation/configuration/basic/) | | `CratisProxiesSegmentsToSkip` | Empty resolves to `0` | [Basic](/arc/backend/csharp/proxy-generation/configuration/basic/) | | `CratisProxiesUseSourceFileAsOutputFile` | Off unless `true` | [Basic](/arc/backend/csharp/proxy-generation/configuration/basic/) | | `CratisProxiesLibraryMode` | `false` | [Library mode](/arc/backend/csharp/proxy-generation/configuration/library-mode/) | | `CratisProxiesEmitInterfaces` | `false` | [Emit interfaces](/arc/backend/csharp/proxy-generation/configuration/emit-interfaces/) | | `CratisProxiesUseProxyFileSuffix` | `false` | [Proxy file suffix](/arc/backend/csharp/proxy-generation/configuration/proxy-file-suffix/) | | `ExcludeType` item | `Include`, `TypeName` | [Exclusions](/arc/backend/csharp/proxy-generation/configuration/type-exclusions/) | | `ExcludeNamespace` item | `Include`, `Namespace` | [Exclusions](/arc/backend/csharp/proxy-generation/configuration/type-exclusions/) | | `NamespaceRoot` item | `Include`, `Namespace`, `Folder` (may be empty) | [Namespace roots](/arc/backend/csharp/proxy-generation/configuration/namespace-roots/) | | `AssemblyToPackageMapping` item | `Include`, `Assembly`, `Package` | [Package mappings](/arc/backend/csharp/proxy-generation/configuration/assembly-package-mappings/) | | `TypeToTsType` item | `Include`, `TypeName`, `TsType`; optional `Package` | [Type mapping](/arc/backend/csharp/proxy-generation/type-mapping/) | | `CratisProxiesSkipCommandNameInRoute` | `false` | [Routing](/arc/backend/csharp/proxy-generation/configuration/routing/) | | `CratisProxiesSkipQueryNameInRoute` | `false` | [Routing](/arc/backend/csharp/proxy-generation/configuration/routing/) | | `CratisProxiesApiPrefix` | `api` | [Routing](/arc/backend/csharp/proxy-generation/configuration/routing/) | | `CratisProxiesIncremental` | `true`; skip discovery when inputs and recorded outputs match | [Output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) | | `CratisProxiesSkipIndexGeneration` | Off unless `true` | [Output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) | | `CratisProxiesSkipOutputDeletion` | `true` in MSBuild; direct executable differs | [Output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) | | `CratisProxiesSkipFileIndexTracking` | `false`; setting `true` is currently ignored by the executable | [Output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) | # Assembly-to-package mappings > Import external model types from an existing npm package. When your project references types from an external assembly that already has a corresponding TypeScript npm package, you can tell the proxy generator to import those types from the package instead of regenerating them locally. ## Use case Consider this solution structure: ```text MyCompany.Shared/ ← Shared class library + npm package (@mycompany/shared) MyCompany.Inventory/ ← Application referencing Shared MyCompany.Purchasing/ ← Application referencing Shared ``` Without a mapping, the proxy generator would regenerate `Money.ts` and `ProductSummary.ts` inside each application’s frontend — duplicating types that already exist in `@mycompany/shared`. With a mapping, those types are imported from the package: ```typescript import { Money, ProductSummary } from '@mycompany/shared'; ``` ## Configuration ```xml ``` Multiple shared libraries: ```xml ``` | Attribute | Description | | --- | --- | | `Include` | MSBuild item identity; not the assembly mapping value | | `Assembly` | C# assembly name (without `.dll` extension) | | `Package` | npm package name to import from | ## Behavior * Types from the mapped assembly are **not** generated as local TypeScript files. * Any command, query, or type that references a mapped type imports it from the configured package. * The mapping covers generated model types and enums in the assembly. Concepts and built-in primitive mappings still follow [type mapping](/arc/backend/csharp/proxy-generation/type-mapping/); a concept ID is not automatically a separately emitted class. * Install the target npm package separately. It must export compatible types and, where deserialization needs them, runtime constructors. The generator does not build or publish that package. The XML blocks are fragments inside your existing `Project` element; the TypeScript import is illustrative of package output. ## CLI With the [executable alias prerequisite](/arc/backend/csharp/proxy-generation/configuration/output-behavior/#direct-executable): ```bash proxygenerator MyCompany.Inventory.dll output-path --skip-output-deletion \ --assembly-to-package=MyCompany.Shared=@mycompany/shared \ --assembly-to-package=MyCompany.UiModels=@mycompany/ui-models ``` # Basic options > Output location, namespace trimming, source-file grouping, and model decorators. The XML blocks on this page are fragments to place inside your existing `.csproj`’s `Project` element. ## Required ```xml $(MSBuildThisFileDirectory)../Web/src/generated ``` `CratisProxiesOutputPath` is the only required property. It specifies the directory where generated TypeScript files are written — preferably a dedicated generated-only folder, not the frontend root. An empty value disables the post-build target. Review [output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) before selecting a shared or handwritten source directory. ## Namespace segment skipping ```xml 1 ``` `CratisProxiesSegmentsToSkip` controls how many leading namespace segments are stripped when mapping C# namespaces to output folders. **Example:** With namespaces `Api.MyFeature`, `Domain.MyFeature`, and `Read.MyFeature` and `SegmentsToSkip=1`: Without skipping: ```text Api/MyFeature/ Domain/MyFeature/ Read/MyFeature/ ``` With skipping: ```text MyFeature/ ``` For more control over namespace-to-folder mapping, see [Namespace Roots](/arc/backend/csharp/proxy-generation/configuration/namespace-roots/). ## Source file as output file ```xml true ``` By default, each artifact gets its own file: command and model type names determine their filenames, while queries use method names. When `CratisProxiesUseSourceFileAsOutputFile` is `true`, artifacts whose owning types resolve to the same source filename and output folder are combined into a `.ts` file named after that source file. This does not copy the C# directory tree. **Illustrative layout:** With namespace `MyApp.Accounts` and one segment skipped, `AccountCommands.cs` containing `CreateAccount`, `UpdateAccount`, and `DeleteAccount`, each with resolvable debug information, generates: Default: ```text Accounts/ ├── CreateAccount.ts ├── UpdateAccount.ts └── DeleteAccount.ts ``` With `CratisProxiesUseSourceFileAsOutputFile=true`: ```text Accounts/ └── AccountCommands.ts ``` > **Note:** The resolver reads an embedded portable PDB or an adjacent portable `.pdb`. Resolution is **per type**, using method debug-document information in the input assembly. Having a PDB is not sufficient for every type: enums and other types without resolvable methods remain in their own files. Types from other assemblies are not guaranteed a source-file mapping. No sibling/namespace guess fills that gap. Queries use their declaring read-model/controller type’s source mapping. The first resolvable method document supplies a type’s filename; partial types are not split across files. Source grouping changes filenames, not namespace-derived folders. Avoid unrelated source files with identical basenames in the same output folder, and inspect imports after switching modes. Generate proxies during development with a Debug build (`dotnet build -c Debug`), then commit the generated TypeScript. Release and publish builds can consume those committed proxies without regenerating them. This is a recommended workflow rather than a Release restriction: the generator still runs in Release whenever `CratisProxiesOutputPath` is configured. ### CLI With the [executable alias prerequisite](/arc/backend/csharp/proxy-generation/configuration/output-behavior/#direct-executable): ```bash proxygenerator assembly.dll output-path --skip-output-deletion --use-source-file-as-output-file ``` ## File header Use your application’s copyright and license text rather than a framework license. Set `CratisProxiesFileHeader` in the project: ```xml Copyright (c) Example Company. All rights reserved.\nGenerated client: {fileName} ``` The generator prefixes each line with `//` followed by a space. Literal `\n`, MSBuild `%0A`, and actual Windows or Unix newlines separate lines. `{fileName}` expands to the output filename, including its extension (for example, `CreateAccount.proxy.ts`). Header text is transported to the generator as an encoded argument, not interpolated into the shell command. Header selection follows this order: 1. A non-empty `CratisProxiesFileHeader` supplies the template. The special value `none` (case-insensitive) disables the header, including the EditorConfig fallback. 2. An unset or empty property uses `file_header_template` from `.editorconfig`, starting in the C# project directory and walking upward. The nearest applicable value wins; `root = true` stops the search after that file. 3. Without either configuration, no copyright or license header is emitted. The generated-file notice and tracking metadata remain. For example, this `.editorconfig` also configures the proxy header: ```ini root = true [*.{cs,vb}] file_header_template = Copyright (c) Example Company. All rights reserved.\nAll rights reserved. ``` The fallback honors only `[*]`, `[*.cs]`, and extension-list sections such as `[*.{cs,vb}]` containing the exact `cs` extension. Within one file, the last matching section’s value wins. Path-specific patterns and TypeScript-only sections are not used. An empty template or `unset` (case-insensitive) suppresses inherited templates. Projects using Cratis’s configured EditorConfig template retain their Cratis header. The configured header applies to commands, snapshot and observable queries, models, interfaces, enums, flags enums, and newly created `index.ts` files. Grouped output gets one header using the combined filename. Existing index comments and imports are preserved, so changing this setting does not replace headers in existing index files. ## Decorator metadata Generated types use `@field(...)` property decorators and `@derivedType(...)` class decorators. The decorators keep the runtime serialization metadata beside the type and property they describe, with no proxy-generator configuration required. TypeScript 5.2 and newer support these decorators through the standard decorator transform. Leave `experimentalDecorators` unset or set it to `false`; `@cratis/fundamentals` consumes the standard decorator metadata when the generated class is defined. Existing applications can continue using TypeScript’s legacy decorator transform with `experimentalDecorators` set to `true`. The generated proxy source is the same in both modes, so you can change compiler modes without regenerating a different proxy shape. If Babel transforms the generated proxies, configure its decorators plugin for the `2023-11` protocol. Hermes executes the JavaScript that Babel produces; Hermes does not transform decorator syntax itself, so the Babel step must run before the bundle reaches Hermes. # Emit Interfaces > Generate TypeScript interfaces instead of decorated classes for proxy types, and the runtime behavior you give up. By default the generator emits each type as a **class** carrying `@field` decorators: ```ts import { field } from '@cratis/fundamentals'; export class Grid extends Panel { @field(RowDefinition, true) rows!: RowDefinition[]; } ``` The decorators carry runtime type information so `@cratis/fundamentals` can reconstruct an instance from JSON — which is what a command or query proxy needs. They also mean every generated file imports `@cratis/fundamentals`. **Emit-interfaces mode** produces plain TypeScript interfaces instead: ```ts export interface Grid extends Panel { rows: RowDefinition[]; } ``` Same shape, same documentation, same inheritance, same imports for the types a property refers to — but no class, no decorators, and no import of `@cratis/fundamentals`. ## Enabling it ```xml true ``` ### CLI ```bash proxygenerator assembly.dll output-path --library-mode --emit-interfaces ``` ## When to use it Use it for a model that is **built and read but never deserialized** through Fundamentals’ `JsonSerializer` — and, in particular, for a package that must not take a runtime dependency. A package deliberately published with no dependencies cannot accept one just to describe its own shapes, which otherwise rules out generating its TypeScript at all and leaves it hand-maintained. Do **not** use it for command and query proxies. Those are deserialized from HTTP responses, and without the decorators the deserializer has no type information to reconstruct with. ## What it does not remove Only the decorators and the import they need. A model that uses types **Fundamentals provides** still imports those, because they are the TypeScript representation of the C# types: ```ts import { TimeOnly } from '@cratis/fundamentals'; export interface ScheduleTriggerSourceSyntax extends TriggerSourceSyntax { at: TimeOnly; } ``` That import is a type, not a decorator, and no emission mode can remove it — the property genuinely is a `TimeOnly`. ## Combining with library mode The two are usually used together: [library mode](/arc/backend/csharp/proxy-generation/configuration/library-mode/) is what makes the generator emit a model that no command or query references, and emit-interfaces is what lets the result stay dependency-free. Neither implies the other. Emit-interfaces on its own changes how the types reached from commands and queries are rendered, which is rarely what you want — see the warning above. # Library mode > Generate a shared model surface beyond endpoint-referenced types. Normal generation starts with endpoints and strongly typed identity details, then follows their referenced models. Library mode also collects types that no endpoint uses, so a shared library can expose its model surface to TypeScript consumers. ## Enable library mode Add this fragment inside your existing project: ```xml true ``` The collection pass walks project assemblies, including public non-abstract types, enums, and interfaces. Open generic definitions are skipped, along with their type parameters: neither has a concrete shape to emit. Abstract classes are not collected directly, but are still generated when something reachable derives from one — a concrete type’s base class is followed, so an inheritance chain arrives intact rather than with its roots missing. This is model generation, not a translation of arbitrary .NET methods or implementations. [Exclusions](/arc/backend/csharp/proxy-generation/configuration/type-exclusions/) still apply, and [package-mapped types](/arc/backend/csharp/proxy-generation/configuration/assembly-package-mappings/) are imported rather than regenerated. ## Default classes versus plain interfaces Library mode does **not** imply interface output. Models default to TypeScript classes with decorators and runtime Fundamentals metadata, just as in ordinary proxy generation. The executable separately accepts `--emit-interfaces` for shape-only model output. Interfaces have no runtime constructors or deserialization metadata; use this for types you construct/read as plain objects, not as a drop-in replacement for model constructors in query or identity clients. Command/query templates are not converted into dependency-free interfaces by this flag. See [emit interfaces](/arc/backend/csharp/proxy-generation/configuration/emit-interfaces/) for when to use interface output and how to enable it via the MSBuild property or CLI flag. ## Combine output controls This project fragment excludes implementation namespaces and intentionally strips `MyApp.Models` without adding a base folder: ```xml true ``` `Folder=""` is valid: `MyApp.Models.Billing` maps to `Billing/`. `Include` is the MSBuild item identity; `Namespace` and `Folder` are the generator’s metadata. See [namespace roots](/arc/backend/csharp/proxy-generation/configuration/namespace-roots/) for matching rules. # Namespace roots > Map namespace prefixes to generated output folders. A namespace root pins a namespace as the base of the output folder hierarchy and places its output under a named base folder. When a type’s namespace equals a configured root or starts with that root followed by a dot, the root prefix is stripped and the remainder is placed under the specified folder. This is an alternative to [`CratisProxiesSegmentsToSkip`](/arc/backend/csharp/proxy-generation/configuration/basic/#namespace-segment-skipping) that works by name rather than by a fixed segment count, making it more resilient when namespace depths vary across a project. ## Configuration ```xml ``` | Attribute | Description | | --- | --- | | `Include` | MSBuild item identity; the generator reads the metadata below, not this name | | `Namespace` | The C# namespace prefix to match | | `Folder` | Output base folder; an empty value intentionally strips the namespace prefix without adding a folder | **Example:** With namespace root `MyApp.Features` → `features`: | C# namespace | Output folder | | --- | --- | | `MyApp.Features.Auth.Registration` | `features/Auth/Registration/` | | `MyApp.Features.Billing.Invoices` | `features/Billing/Invoices/` | | `MyApp.Features.Auth` | `features/Auth/` | | `MyApp.Features` | `features/` | | `MyApp.Other` | *(falls back to segment-skip logic — namespace root does not apply)* | ## Multiple roots You can declare multiple roots. The longest matching namespace wins: ```xml ``` ## Priority Namespace roots **only take effect for types whose namespace matches the root**. Types that do not match any configured root fall back to `CratisProxiesSegmentsToSkip` as normal. These XML blocks belong inside the existing project’s `Project` element. Namespace roots affect **output folders only**, not conventional API routes. ## CLI With the [executable alias prerequisite](/arc/backend/csharp/proxy-generation/configuration/output-behavior/#direct-executable), pass one or more `--namespace-root` flags using `=` to separate the namespace from the folder: ```bash proxygenerator assembly.dll output-path --skip-output-deletion \ --namespace-root=MyApp.Features=features \ --namespace-root=MyApp.SharedTypes=shared ``` # Output behavior > Incremental writes, destructive cleanup, and barrel generation settings. ## Defaults by entry point | Entry point | Output deletion default | | --- | --- | | NuGet MSBuild integration | `CratisProxiesSkipOutputDeletion=true`: retain the directory and update incrementally | | Direct executable | Deletes the output directory unless `--skip-output-deletion` is supplied | Use a dedicated, generated-only directory with one generation owner. Incremental mode does **not** disable orphan cleanup or guarantee preservation of a mixed source tree. ## Incremental discovery The MSBuild integration defaults `CratisProxiesIncremental` to `true`. It stores `cratis-proxies.state.json` under the project’s framework- and configuration-specific `$(IntermediateOutputPath)`. On an unchanged build, the executable checks a content fingerprint and the recorded outputs before loading project assemblies or discovering types. A cache hit prints `Proxies are up to date; skipping discovery` and does not change proxy bytes or timestamps. The fingerprint includes the target and every managed dependency resolved by the generator, including non-project and local-package assemblies and the resolved Fundamentals assembly, together with their adjacent XML documentation and PDB files. It also includes the dependency manifest, generator identity, resolved runtime paths, command-line settings and file-header template. Replacing a dependency’s bytes invalidates the shortcut even when its package version and dependency manifest stay unchanged. Missing or unreadable inputs disable the shortcut. Every recorded proxy and managed index must still have its saved size, last-write time and SHA-256 content hash. A modified output invalidates the incremental state and reruns generation, even for a same-length edit with a preserved timestamp. Validation reads only the recorded outputs; it does not scan the source tree. Detection does **not** guarantee byte-for-byte restoration: the existing write shortcut trusts inline generated-content hashes and may leave a manual edit in place when that metadata still matches the newly generated content. Deleting the state file forces discovery on the next build. `dotnet clean` removes it too. To disable the shortcut and always run discovery: ```xml false ``` This does not disable the existing content-hash write shortcut described below. Full generation refreshes state only on success; a failed run never leaves a successful state for its new inputs. ## Incremental generation This fragment belongs inside your existing project: ```xml true ``` The generator compares generated content hashes with inline metadata on existing files. A matching hash lets it skip the write and preserve both filesystem and inline timestamps. Renamed or removed artifacts are still cleaned up by [file tracking](/arc/backend/csharp/proxy-generation/file-index-tracking/). Do not edit generated files manually. The metadata/hash shortcut is not a merge tool, and changes to generated content can be overwritten on later builds. ## Full regeneration To rebuild a **disposable output directory** from scratch: ```xml false false ``` Full regeneration deletes the entire output directory This recursively deletes the entire output directory, including handwritten files and indexes, before writing proxies. Never point this mode at your frontend source root. Generated metadata embeds the generation time in file content. Deleting existing files prevents the hash shortcut from preserving that timestamp, so unchanged backend contracts can still produce Git diffs on regeneration. ## Index file generation By default, the generator manages `index.ts` exports for generated files. Disable that update phase with: ```xml true ``` This skips barrel creation/update, **not deletion**. Full output deletion still removes indexes, and orphan-directory cleanup can delete an `index.ts` even when this property is true. Keep a handwritten public barrel outside the generator’s output tree if you need ownership isolation. ## File tracking limitation `CratisProxiesSkipFileIndexTracking` is still exposed by the build package and forwards `--skip-file-index-tracking`. The current executable **does not parse that flag**. Setting it to `true` does not disable metadata scanning or stale-file deletion. There is no supported orphan-cleanup opt-out in the current executable. The old JSON file index and `--project-directory` setting are not used by current tracking. See [file tracking and preservation limits](/arc/backend/csharp/proxy-generation/file-index-tracking/) before changing cleanup settings. ## Direct executable CLI examples in this section assume `proxygenerator` is an application-provided alias or wrapper for the packaged .NET executable; the build package does not install that shell command. The first arguments are the compiled assembly path, output path, and optional namespace segment count. ```bash proxygenerator assembly.dll output-path --skip-output-deletion --skip-index-generation ``` This retains the output directory and skips barrel updates, but still removes orphaned marked files. Use only a disposable or generator-owned `output-path`. Direct executable calls always discover types unless given `--state-file=`. To opt in explicitly: ```bash proxygenerator assembly.dll output-path --skip-output-deletion --state-file=obj/proxies.state.json ``` Choose a state file outside the generated output directory and use a separate file for each target framework, configuration and generation owner. Calls without `--state-file` keep the existing generation behavior. # Proxy File Suffix > Add a .proxy suffix to generated TypeScript file names, and what changes when you switch it on or off. By default each generated file is named after the type it represents, or after its C# source file when `CratisProxiesUseSourceFileAsOutputFile` is on: ```text Features/Authors/RegisterAuthor.ts Features/Authors/AllAuthors.ts ``` Set `CratisProxiesUseProxyFileSuffix` to name them `Name.proxy.ts` instead: ```xml true ``` ```text Features/Authors/RegisterAuthor.proxy.ts Features/Authors/AllAuthors.proxy.ts ``` Use it when generated proxies share folders with hand-written TypeScript: the suffix tells the two apart at a glance, and lets tooling - lint rules, code owners, coverage exclusions - match generated files with a single `*.proxy.ts` pattern. ## What changes with it * **File names.** Every generated file carries the suffix, including combined files named after a C# source file. * **Imports between generated files.** A generated file imports another as `./RegisterAuthor.proxy`. Types mapped to a module of your own through a type mapping or a package mapping are imported exactly as mapped, never suffixed. * **Barrels.** Generated `index.ts` files export the suffixed names, as in `export * from './RegisterAuthor.proxy';`, so imports through a folder barrel keep working unchanged. Code that imports a generated file directly by path must use the suffixed name once the option is on. ## Switching an existing project Turning the option on - or off - in a project that already has generated files replaces them: the next build writes the new names and removes the previously generated ones, like any other generated file that is no longer produced. With the default incremental output, the switch removes only files the generator produced. The suffix does not protect handwritten files from the generator’s other cleanup: [full regeneration](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) deletes the whole output directory, and orphan-directory cleanup can remove an `index.ts`. # Routing > Align conventional model-bound proxy routes with Arc runtime endpoint options. ## Which routes these settings affect The settings below control **conventional model-bound** routes. Controller-based proxies derive routes from ASP.NET controller/action attributes. An explicit `[Path]` on a model-bound command, query method or read-model type is used as the route instead; the query method’s attribute wins over the read-model type’s attribute. The generator does not prepend the configured API prefix to that explicit path. Import the shared `[Path]` from `Cratis.Arc.Queries.ModelBound` for both commands and queries; a command’s path is declared on its own type rather than inherited from a base type. [Namespace roots](/arc/backend/csharp/proxy-generation/configuration/namespace-roots/) control output folders, not route construction. ## Conventional routes This MSBuild fragment belongs inside your existing project: ```xml api 1 false false ``` The generator skips the first namespace segment, converts the remaining segments and artifact name to kebab case, and prefixes `api`. For namespace `MyApp.Orders.Registration`, command `CreateOrder` generates `/api/orders/registration/create-order`. Without the explicit segment count (default `0`), `my-app` also appears in the route. A query uses its **method name**, not the read-model type name, as the final segment. Defaults include both command and query names. ## Name skipping and conflict fallback Set `CratisProxiesSkipCommandNameInRoute` or `CratisProxiesSkipQueryNameInRoute` to `true` to omit that final segment. If more than one discovered command shares the namespace after segment skipping, command names are restored. Query conflict detection counts eligible methods across read-model types in that namespace and restores method names when needed. For `MyApp.Orders.Registration` with one skipped segment and command-name skipping: | Discovered commands | Generated routes | | --- | --- | | Only `CreateOrder` | `/api/orders/registration` | | `CreateOrder` and `UpdateOrder` | `/api/orders/registration/create-order`, `/api/orders/registration/update-order` | This is a conventional namespace conflict fallback, not a global collision detector for custom paths or controller routes. ## Match the server Build properties do not change the running application’s endpoint configuration. Align them with `ArcOptions.GeneratedApis`: | Generator setting | Runtime property | Runtime default | | --- | --- | --- | | `CratisProxiesApiPrefix` | `RoutePrefix` | `"api"` | | `CratisProxiesSegmentsToSkip` | `SegmentsToSkipForRoute` | `0` | | `CratisProxiesSkipCommandNameInRoute` | `IncludeCommandNameInRoute` (**inverse**) | `true` | | `CratisProxiesSkipQueryNameInRoute` | `IncludeQueryNameInRoute` (**inverse**) | `true` | Inspect a generated client’s `route` and compare it to the hosted endpoint after configuration changes. See [model-bound commands](/arc/backend/csharp/commands/model-bound/) and [model-bound queries](/arc/backend/csharp/queries/model-bound/). ## CLI With the [executable alias prerequisite](/arc/backend/csharp/proxy-generation/configuration/output-behavior/#direct-executable), this invocation skips one namespace segment and requests namespace-only routes: ```bash proxygenerator assembly.dll output-path 1 --skip-output-deletion --api-prefix=v1 --skip-command-name-in-route --skip-query-name-in-route ``` The corresponding runtime prefix must be `v1`; conflict fallback still applies. # Type exclusions > Omit selected types and namespaces from proxy output. Exclusions apply to command/query descriptor types, transitively collected models, and [library-mode](/arc/backend/csharp/proxy-generation/configuration/library-mode/) types. They control generation, **not backend authorization or endpoint exposure**. Ensure remaining proxies do not reference an excluded type without a suitable replacement mapping. ## Exclude specific types Place this fragment inside your existing project’s `Project` element: ```xml ``` `Include` supplies the MSBuild item identity. `TypeName` supplies the fully qualified C# type name the generator matches. Query exclusion is based on the descriptor’s owning type, not an individual method name. ## Exclude namespaces You can combine type exclusions and namespace patterns in the same item group: ```xml ``` The generator reads `Namespace` metadata, not the `Include` identity. | Pattern | Matches | | --- | --- | | `MyApp.Internal*` | Any namespace starting with `MyApp.Internal` | | `MyApp.*.Internal` | Namespaces such as `MyApp.Foo.Internal` or `MyApp.Foo.Bar.Internal` | | `MyApp.Internal` | The exact namespace only | `*` matches any sequence of characters, including dots. Be deliberate: `MyApp.Internal*` also matches `MyApp.InternalTools`. ## CLI With the [executable alias prerequisite](/arc/backend/csharp/proxy-generation/configuration/output-behavior/#direct-executable), use repeatable flags. Quote wildcards so the shell does not expand them: ```bash proxygenerator assembly.dll output-path --skip-output-deletion \ --exclude-type=MyApp.Internal.SecretPayload \ '--exclude-namespace=MyApp.Tests*' ``` # File tracking and cleanup > How inline metadata identifies stale proxies and where preservation guarantees stop. When you rename a backend artifact, its old TypeScript file must disappear or consumers can keep importing a client that no longer has an endpoint. Arc identifies those stale files with inline metadata, rather than a separate JSON index. ## How it works An illustrative generated metadata line: ```typescript // @generated by Cratis. Source: MyApp.Commands.CreateOrder. Time: 2024-12-08T12:00:00.0000000Z. Hash: 1E9523083D8C6457F194B1CB045877E19BE204FB42F9F6E5574B77FC79B3ABDA ``` After generating the current artifacts, the executable: 1. Scans `.ts` files recursively below the output path, excluding `index.ts`. 2. Identifies files with valid generated metadata that were not produced in this run. 3. Deletes those orphaned files and attempts to clean their directories. 4. Updates barrel files unless index generation is skipped. The scan is output-tree-wide, not partitioned by input assembly. **Do not give independent generation runs the same output tree:** one can regard another’s proxies as orphans. For example, renaming `UpdateOrder` to `ModifyOrder` creates `ModifyOrder.ts`, removes the marked `UpdateOrder.ts`, and updates its exports. An unchanged `CreateOrder.ts` retains its timestamp when its existing metadata/hash matches in incremental mode. ## Configuration limits | Setting | Current behavior | | --- | --- | | `CratisProxiesSkipOutputDeletion=true` | Prevents initial recursive output deletion; orphan cleanup still runs | | `CratisProxiesSkipIndexGeneration=true` | Skips barrel creation/update; does not prevent orphan cleanup from deleting an index | | `CratisProxiesSkipFileIndexTracking=true` | Forwarded by MSBuild, but ignored by the executable; **does not disable cleanup** | A manual export can also point at an entire **sibling folder** that has its own `index.ts`/`index.tsx` barrel, or re-export an npm package outright — both are preserved the same way: ```typescript // A hand-written folder with its own barrel (./List/index.ts) export * from './List'; // A hand-written re-export of an entire package export * from '@cratis/components'; export * from './CreateOrder'; export * from './UpdateOrder'; ``` The generator only ever removes a relative export once its target genuinely stops resolving — a sibling file that no longer exists, or a sibling folder whose own barrel is gone. It never considers a bare/package specifier for removal, because it has no way to verify one on disk. Note Unlike every other generated file, `index.ts` never carries the `// @generated by Cratis` marker comment. Because it is a merge target you edit directly rather than a file the generator fully owns, marking it “generated” would make it eligible for the orphan-deletion pass described above, and would cause [`@cratis/eslint-plugin-arc`’s `skip-generated-proxies` processor](/arc/backend/csharp/proxy-generation/linting/) to stop linting the hand-written lines it contains. If you’d rather manage `index.ts` entirely yourself, see [output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/), controlled by `CratisProxiesSkipIndexGeneration`. The executable no longer maintains `.cratis/GeneratedFileIndex.json`, and it does not consume `--project-directory`. Do not create, repair, or configure that retired index as part of a current setup. See [output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) for the different MSBuild and direct-executable deletion defaults. ## Barrel preservation is limited During an index update, the generator recognizes `export * from '…'` lines, adds exports for generated files, and removes recognized exports whose targets are stale. It can retain existing exports to live `.ts`/`.tsx` files; it does not automatically add exports for arbitrary handwritten files. Nonempty non-matching lines, such as imports and comments, are retained when rewriting, but moved before the managed exports. Blank lines and precise formatting are not preserved. This is not a general TypeScript-aware barrel editor. A separate orphan-directory cleanup phase can delete `index.ts` without checking whether it is handwritten. Its emptiness test counts other `.ts`/`.tsx` files and subdirectories, not all file types. A directory containing only an index and non-TypeScript assets can lose its index before directory deletion fails because those assets remain. Skipping index generation does not bypass that phase. For dependable isolation, keep handwritten source, assets, and public barrels **outside** the generated output directory. Do not rely on the skipped-tracking flag as a safety switch. ## Troubleshooting * **A stale proxy remains:** confirm it has valid generated metadata on the first line (legacy) or the fourth line after the exact standard Cratis copyright and license lines and a blank third line, and lies under the configured output path. Unmarked files are not recognized as orphans. * **Unexpected deletions:** inspect the build log, output path, full-deletion setting, and whether another assembly shares that tree. Restore handwritten files from version control before rebuilding with isolated output. * **Unexpected Git diffs:** full deletion recreates inline timestamps; configuration, grouping, or template changes can also change output. * **A barrel is missing or stale:** inspect index-generation settings and cleanup messages. Review the actual exports rather than assuming all index edits survive. Commit generated proxies when your frontend workflow consumes them directly, and review generated deletions in code review. Never edit a generated proxy to repair its backend contract; fix the source or generator configuration and rebuild. # Set up proxy generation > Connect an existing Arc backend build to a dedicated TypeScript output folder. Start with an Arc project that already defines [commands](/arc/backend/csharp/commands/) or [queries](/arc/backend/csharp/queries/). This guide adds proxy generation; it does not create or host those endpoints. ## Add the build package Reference [Cratis.Arc.ProxyGenerator.Build](https://www.nuget.org/packages/Cratis.Arc.ProxyGenerator.Build) in each project whose compiled endpoints you intend to generate. Use a version compatible with your Arc packages and pin it through your normal dependency management. ## Choose a dedicated output folder Add this MSBuild fragment **inside your existing `.csproj`’s `Project` element**: ```xml $(MSBuildThisFileDirectory)../Web/src/generated true ``` The example assumes a sibling `Web` project. Change the path for your layout. Prefer a generated-only directory, with one generation owner, rather than the frontend root. MSBuild defaults to incremental output, but stale generated files are still deleted. [Output behavior](/arc/backend/csharp/proxy-generation/configuration/output-behavior/) explains the destructive operations and direct CLI defaults. ## Install frontend dependencies In your React frontend, install: ```bash npm install @cratis/arc @cratis/arc.react @cratis/fundamentals react ``` Generated command and query files import **both** Arc core and React hooks unconditionally, even if you only instantiate the classes. Generated model decorators and value types use Fundamentals. Use mutually compatible package versions; `@cratis/arc.react` declares React 18 or 19 as a peer dependency. A browser React application also needs its usual renderer and TypeScript setup. See [decorator configuration](/arc/backend/csharp/proxy-generation/configuration/basic/#decorator-metadata) for compiling generated models. These proxies do not generate a `Bindings` setup file. Wrap React consumers in the [`Arc` provider](/arc/frontend/react/arc/). ## Build and inspect the result From the backend project, run this checkpoint: ```bash dotnet build -c Debug ``` After compilation, the build target invokes the generator over the assembly. Look for its command/query counts in the build log and `.ts` files under `Web/src/generated`. By default, a query named `AllAuthors` gets `AllAuthors.ts`; it is not automatically placed inside `Author.ts`. Folder paths come from namespaces. Compile the frontend with its own type-check/build command. This second checkpoint catches missing packages, decorator configuration, and imports that do not match the actual generated paths. ## Keep generation predictable Commit generated proxies if your workflow consumes them without rebuilding the backend. Incremental generation preserves existing files when their generated content hash matches; full deletion recreates metadata timestamps and can cause Git content churn. [Source-file grouping](/arc/backend/csharp/proxy-generation/configuration/basic/#source-file-as-output-file) is opt-in and depends on per-type PDB information. Prefer Debug for generation. To verify Release without regenerating the same output: ```bash dotnet build -c Release -p:CratisProxiesOutputPath= ``` The empty property disables the post-build target for that invocation. Release is not intrinsically excluded from generation. Next, inspect the [generated command](/arc/backend/csharp/proxy-generation/commands/) and [query](/arc/backend/csharp/proxy-generation/queries/) APIs, or configure [namespace roots](/arc/backend/csharp/proxy-generation/configuration/namespace-roots/). # Identity details type generation > Generate runtime model classes from strongly typed identity providers. The proxy generator can automatically discover and generate TypeScript types for your custom identity details. This enables type-safe access to identity information in your frontend application. ## Overview When you implement a strongly-typed identity details provider using `IProvideIdentityDetails`, the proxy generator will: 1. Discover all implementations of `IProvideIdentityDetails` in your assemblies 2. Extract the `TDetails` type parameter 3. Generate TypeScript representations of these types 4. Include them in the standard proxy output alongside commands and queries ## The generic interface Arc provides this generic interface declaration (an API reference excerpt, not a type to redeclare in your application): ```csharp public interface IProvideIdentityDetails : IProvideIdentityDetails where TDetails : class; ``` This interface extends the base `IProvideIdentityDetails` interface but adds type information that the proxy generator can discover and use. For detailed information on implementing identity providers, see the [Identity documentation](/arc/backend/csharp/identity/). ## How discovery works The proxy generator scans all loaded assemblies for classes that: 1. Are concrete (non-abstract) classes 2. Implement `IProvideIdentityDetails` 3. Have a valid type argument for `TDetails` The `TDetails` type is then processed like any other complex type, generating a corresponding TypeScript **class with serialization metadata** by default. Plain interface output is a separate CLI mode; see [library mode](/arc/backend/csharp/proxy-generation/configuration/library-mode/). ## Generated artifacts For each identity details type discovered, the generator creates: 1. **TypeScript class**: A model matching the C# type structure, with `@field` metadata for deserialization 2. **Nested Types**: Any complex types referenced by the details type 3. **Export entry**: Added to the appropriate `index.ts` file when index generation is enabled ## Benefits of using the generic interface | Feature | `IProvideIdentityDetails` | `IProvideIdentityDetails` | | --- | --- | --- | | Runtime behavior | Identical | Identical | | Details-type discovery | No generic details type declared | Generic argument identifies the type to generate | | Proxy generation | No type generated | TypeScript type generated | | Frontend type safety | Manual typing required | Automatic type safety | ## Best practices 1. **Use the Generic Interface**: Always prefer `IProvideIdentityDetails` over the base interface to enable proxy generation. 2. **Keep Types Simple**: Identity details types should be simple DTOs without complex logic or dependencies. 3. **Avoid Sensitive Data**: Never include sensitive information like tokens or passwords in identity details. 4. **Use Records**: Consider using C# records for immutable identity detail types. ## Frontend integration The generated TypeScript types can be used with the identity system in `@cratis/arc/identity`. The types ensure that your frontend code has compile-time type safety when accessing identity details. Pass the generated class as the details constructor when configuring [React identity](/arc/frontend/react/identity/). A TypeScript interface alone carries no runtime field metadata. Identity details are client-visible UI data, not an authorization boundary. # Lint generated proxies > Configure consumer lint rules for generated public interfaces, or skip generated files with the Arc processor. Generated proxy files live inside your source tree, next to the commands and queries they came from — which means your ESLint config sees them too, unless you tell it not to. ## Lint with your application policy The C# generator emits a standard license header, followed by the unchanged `// @generated by Cratis` metadata format. Fourth-line recognition requires the standard Cratis copyright line, license line, and blank third line; a marker inside an authored multiline template literal does not qualify. Older first-line markers remain recognized. Generated output contains no blanket ESLint disable directives or `@ts-ignore` comments, so you can keep unused-disable reporting at error, including with a minimal TypeScript parser configuration that has no rule plugins. If you lint generated files, your consumer configuration must permit their existing exported empty interfaces, including interfaces with inherited members but no own members. These are supported public shapes: they preserve declaration merging and existing assignability. Configure the applicable `@typescript-eslint/no-empty-interface` and `@typescript-eslint/no-empty-object-type` policies accordingly. This is a target-language contract, not an unused-directive waiver; keep `reportUnusedDisableDirectives` at `error`. Application checks such as license headers, named import-member sorting, and TypeScript-comment bans can remain active. Import members are sorted without reordering imported modules. Generated response hooks supply their response generic rather than suppressing a type error. Output is not promised to pass arbitrary library-style configurations that prohibit a supported public interface shape. Other application-specific restrictions may still need consumer configuration; for example, React hook rules can flag the generated static `.use()` methods as hook calls inside a class. If you prefer not to lint generated files at all, use the opt-in processor below. It does not relax checks on your hand-written source. ## Skip generated files A generated `.ts` file carries a `// @generated by Cratis` header and a `**DO NOT EDIT**` banner, and the proxy generator rewrites it whenever a build regenerates its contract (by default Arc skips files whose generated content is unchanged). Generation follows `CratisProxiesOutputPath`, not the build configuration, so a Release build with that path set regenerates too. Any lint finding on that file is unactionable: you cannot fix it by hand without the fix being discarded on the next build, and you cannot fix the generator’s output style through your project’s lint rules. Left unconfigured, a project either accumulates lint noise it can never resolve, or hand-maintains a path allow-list that has to be kept in sync with every new command and query folder. ## Configure the skip processor [`@cratis/eslint-plugin-arc`](https://www.npmjs.com/package/@cratis/eslint-plugin-arc) ships a `skip-generated-proxies` processor that detects the `// @generated by Cratis` header and skips the file wholesale. Because it keys on the header rather than a path pattern, it works regardless of where a proxy sits — intermixed with hand-written `.ts`/`.tsx` files in the same slice folder, a layout many projects choose by pointing `CratisProxiesOutputPath` at their source root — with no allow-list to maintain. ### Install ```sh yarn add -D @cratis/eslint-plugin-arc @cratis/eslint-config eslint ``` ### Use eslint.config.mjs ```js import cratis from '@cratis/eslint-config'; import arc from '@cratis/eslint-plugin-arc'; export default [ ...cratis.configs.consumer, ...arc.configs.recommended, // …your project rules ]; ``` With `arc.configs.recommended` composed in, a proxy file is never linted — not even for formatting — so nothing about it shows up as a finding to triage. ## Other rules in the plugin The plugin also ships two rules that are not about generated files, but are worth enabling alongside the processor if you use MVVM view models or MobX: | Rule | What it does | | --- | --- | | `no-hooks-in-view-model` | Disallows React hook calls inside MVVM view models (classes named `*ViewModel`). A view model must be a plain, React-free class — inject a Cratis abstraction instead of calling a hook. | | `no-direct-mobx-react-import` | Disallows importing directly from `mobx-react`/`mobx-react-lite`. The MobX binding is an internal detail of `@cratis/arc.react.mvvm`, which re-exports `observer` — import it from there so the binding can evolve without breaking consumers. | `no-hooks-in-view-model` takes options to fit a project’s own naming conventions: ```js '@cratis/arc/no-hooks-in-view-model': ['error', { classSuffix: 'ViewModel', // class-name suffix that marks a view model hookPattern: '^use[A-Z]', // bare-identifier calls treated as hooks additionalHooks: ['injectQuery'], // extra call names to forbid }] ``` Only bare-identifier calls (`useState(...)`) are flagged — a member call like `this.useDefaults()` is not, so a view-model method that merely starts with `use` is safe. ## See also * [File Index Tracking](/arc/backend/csharp/proxy-generation/file-index-tracking/) — the `// @generated by Cratis` header this processor keys on, and why `index.ts` deliberately does not carry it. # Query proxy generation > Query discovery, parameter names, hook tuples, and paging eligibility. ## Discovery and output The generator supports [controller-based queries](/arc/backend/csharp/queries/controller-based/) with `[HttpGet]` and eligible static query methods on model-bound `[ReadModel]` types. Only public or internal static methods with supported return shapes qualify: private helpers, compiler-generated methods (including local functions), special-name methods, and open generic methods are excluded. Injected dependencies are not client parameters. See [model-bound queries](/arc/backend/csharp/queries/model-bound/) for backend definitions. One-shot queries return a scalar model or collection. A model-bound observable query returns `ISubject` of the read model or a collection of it, and generates an observable client; the runtime does not discover a model-bound method returning a bare `IObservable`, and [ARC0001](/arc/backend/csharp/code-analysis/arc0001/) reports it. A model-bound method returning `IAsyncEnumerable` is served at runtime but currently gets **no** generated proxy, so call it without a proxy or return `ISubject` instead. Their transport can be SSE or WebSocket; the backend source must actually emit updates. No Chronicle integration is required. For a query method named `AuthorsByName` with client parameters, the generator emits: * `AuthorsByNameParameters` (**no `I` prefix**). * `AuthorsByName`, extending `QueryFor` or `ObservableQueryFor`. * A validator when extractable rules exist, plus route and parameter metadata. A parameterless query has no parameters interface and uses the base class’s default parameter type. Collection results use `TModel[]`. The default output is a file named after the query method; [source-file grouping](/arc/backend/csharp/proxy-generation/configuration/basic/#source-file-as-output-file) can change that filename. Controller methods marked `[AspNetResult]` are excluded from normal proxy discovery. ## React return tuples These are API shapes, **not complete application examples**. `result` is a `QueryResultWithState` in every row. | Generated hook | Return tuple | | --- | --- | | One-shot `use()` / `useSuspense()` | `[result, perform, setSorting]` | | One-shot enumerable `useWithPaging()` / `useSuspenseWithPaging()` | `[result, perform, setSorting, setPage, setPageSize]` | | Observable scalar `use()` / `useSuspense()` | `[result]` | | Observable enumerable `use()` / `useSuspense()` | `[result, setSorting]` | | Observable enumerable `useWithPaging()` / `useSuspenseWithPaging()` | `[result, setSorting, setPage, setPageSize]` | Destructure the tuple before reading `result.data`. State includes `isPerforming`, `isReady`, `isSuccess`, `isAuthorized`, `isValid`, `validationResults`, `hasExceptions`, `exceptionMessages`, and `paging`. There is no generated `isLoading` or `error` property, nor a connection-state property on this result. For parameterized proxies, `use(args)` takes the generated parameters object; enumerable queries may also accept sorting after it. For parameterless enumerable proxies, sorting is the first argument. `useWithPaging(pageSize, …)` inserts page size before those arguments. ## Paging and change streams Only **enumerable** proxies receive `useWithPaging()`, `useSuspenseWithPaging()`, and generated sort helpers. This is a client API eligibility rule, not proof that your backend applies paging. Ordinary automatic query-pipeline paging uses `IQueryable`; an observable provider such as Arc’s MongoDB `Observe()` can apply paging through `QueryContext` instead. See [backend paging](/arc/backend/csharp/queries/model-bound/paging/). Only observable enumerable proxies receive `useChangeStream()`. Its argument positions differ: | Proxy shape | Call shape | | --- | --- | | Parameterized | `useChangeStream(args?, getKey?, sorting?)` | | Parameterless | `useChangeStream(getKey?, sorting?)` | Thus `useChangeStream(undefined, getKey)` fits a parameterized proxy, not a parameterless one. This hook returns a `ChangeSet`, not a query-result tuple. See [change streams](/arc/frontend/react/queries/change-stream/). ## Routes and HTTP methods [Routing options](/arc/backend/csharp/proxy-generation/configuration/routing/) govern conventional model-bound routes. Controller routes and explicit query `[Path]` values follow their own declarations. Name-skipping uses the **query method name**, not the read-model type name, and conflict fallback can restore it. For **model-bound queries**, `[QueryHttpMethod]` metadata can select `Get`, `Query`, or `Auto` in generated clients. The method attribute takes precedence over the read-model attribute. Controller query discovery does not extract this metadata, so the attribute does not configure generated controller clients. See [using the HTTP QUERY method](/arc/backend/csharp/queries/using-the-http-query-method/) for runtime and infrastructure requirements. Every generated query class also declares a `defaultValue` — the value the query result holds before the first response arrives. A single-instance query’s `defaultValue` is a typed empty placeholder, `{} as TModel`, so the property is typed as the model without asserting a shape it does not yet have; an enumerable query’s `defaultValue` is simply `[]`. Continue with [React query usage](/arc/frontend/react/queries/usage/) and [validation extraction](/arc/backend/csharp/proxy-generation/validation/). # Type mapping > .NET-to-TypeScript model types, runtime metadata, and enum generation. The proxy generator translates the .NET types on your commands, queries and read models into TypeScript types on the generated proxies. This page records that translation, so you can tell from the C# what the browser will actually receive. ## Primitive and common types | .NET type | TypeScript type | Metadata constructor | Imported from | | --- | --- | --- | --- | | `bool` | `boolean` | `Boolean` | — | | `string`, `char`, `Uri` | `string` | `String` | — | | `byte`, `sbyte`, `short`, `int`, `long`, `ushort`, `uint`, `ulong`, `float`, `double`, `decimal` | `number` | `Number` | — | | `DateTime`, `DateTimeOffset` | `Date` | `Date` | — | | `DateOnly` | `DateOnly` | `DateOnly` | `@cratis/fundamentals` | | `TimeOnly` | `TimeOnly` | `TimeOnly` | `@cratis/fundamentals` | | `Guid` | `Guid` | `Guid` | `@cratis/fundamentals` | | `TimeSpan` | `TimeSpan` | `TimeSpan` | `@cratis/fundamentals` | | `Cratis.Geospatial.Point`, `LineString`, `Polygon` | same name | same name | `@cratis/fundamentals` | | `object`, `JsonNode`, `JsonObject`, `JsonArray`, `JsonDocument` | `Record` | `Object` | — | The metadata constructor is passed to the generated `@field(...)` decorator, which records the runtime type used during deserialization. An enum becomes a TypeScript `enum` and travels as its underlying number. A `ConceptAs` is unwrapped to `T` and mapped by this same table. A `Nullable` is unwrapped to `T` and the generated property is declared optional. This mapping holds equally for a query or command **parameter**, not only for a read model or command **property** — a query method taking a plain or nullable enum argument generates the same `enum`-typed parameter as an equivalent property would. Collections become arrays. A dictionary becomes `Record` when its key maps to `string`, and `ValueMap` otherwise. ## Enum generation Referenced enums are generated with camelCase member names and numeric values. These paired declarations illustrate the type output; the C# enum must be reachable from a discovered endpoint/model or included through library mode: ```csharp public enum ReadModelStatus { Unknown = 0, Active = 1, Inactive = 2, Archived = 3 } ``` ```typescript export enum ReadModelStatus { unknown = 0, active = 1, inactive = 2, archived = 3, } ``` ### Flags enums `[Flags]` uses a dedicated template that also exports `all`, combining every nonzero member with bitwise OR: ```csharp [System.Flags] public enum AnchorEdges { None = 0, Top = 1 << 0, Right = 1 << 1, Bottom = 1 << 2, Left = 1 << 3, } ``` ```typescript export enum AnchorEdges { none = 0, top = 1, right = 2, bottom = 4, left = 8, } export const allAnchorEdges = AnchorEdges.top | AnchorEdges.right | AnchorEdges.bottom | AnchorEdges.left; ``` The zero-valued `none` contributes nothing and is excluded. Composite nonzero members are included too. This is JavaScript numeric/bitwise output, not a `bigint` mapping: avoid assuming .NET 64-bit flag values retain their semantics under JavaScript’s 32-bit bitwise operators. Large integral values also face JavaScript number precision limits. ## Dates, times and instants `DateTime` and `DateTimeOffset` denote instants, so they map to the JavaScript `Date` that also denotes one. `DateOnly` and `TimeOnly` do not. A calendar date has no time and no zone; a time of day has no date. Both cross the wire as their ISO-8601 string — `"2026-05-12"` and `"14:30:45"` — and each has a type of its own in `@cratis/fundamentals` that holds exactly that, with no instant invented for it. This matters because a `Date` cannot hold either value without inventing one that was never sent: ```typescript new Date('2026-05-12'); // 2026-05-12T00:00:00.000Z — UTC midnight, an instant nobody sent new Date('14:30:45'); // Invalid Date — a time of day is not a date at all ``` The first is the more dangerous of the two, because it looks like it worked. UTC midnight read back through any browser-local getter reports the *previous* day everywhere west of UTC, while remaining correct at or east of it — so the bug is invisible to a developer in Europe and constant for a user in the Americas: ```typescript const asAnInstant = new Date('2026-05-12'); asAnInstant.toLocaleDateString('en-CA', { timeZone: 'Europe/Oslo' }); // '2026-05-12' asAnInstant.toLocaleDateString('en-CA', { timeZone: 'America/New_York' }); // '2026-05-11' ← wrong ``` `DateOnly` holds the three parts the server sent, so there is no instant to convert and nothing to shift. The following are illustrative expressions assuming a deserialized `readModel` with a `DateOnly`-typed `dueDate`: ```typescript readModel.dueDate.toString(); // '2026-05-12', in every time zone readModel.dueDate.year; // 2026 readModel.dueDate.day; // 12 ``` Where you genuinely need a `Date` — to feed a date picker, or to do calendar arithmetic — `toDate()` constructs one at midnight in the local zone. It is a method rather than what the value is, precisely because calling it invents a time, and that choice belongs at the call site making it: ```typescript const localMidnight = readModel.dueDate.toDate(); ``` `TimeOnly` works the same way, with `hour`, `minute`, `second` and `millisecond`. ## Declaring how your own types cross the wire The table above is the default. A `TypeToTsType` item overrides it, and is also how you declare a type the generator has never seen: ```xml ``` Every `DateOnly` then generates as `LocalDate`, imported from `@acme/time`. Omit `Package` to generate a bare TypeScript type with no import. Mappings are consulted **ahead of** the built-in table, so this corrects an existing type as readily as it declares a new one. A build that configures none generates exactly what it generated before. Mappings change both the type and deserialization The defaults are chosen to be right without configuration — reach for a mapping when your application wants a *different* type, not to work around a default that is wrong. Whatever you map to has to be able to deserialize from what the server actually sends. `DateOnly` arrives as `"2026-05-12"` and `TimeOnly` as `"14:30:45"`, so register a converter for the type you map to with `JsonSerializer.registerConverter`, or the value arrives as the raw string wearing the declared type’s name. ## Unmapped types A collected complex type not in the table above, and not declared through `TypeToTsType`, normally generates as a TypeScript class with runtime field metadata. Its folder follows namespace configuration; its filename follows the artifact name or [source-file grouping](/arc/backend/csharp/proxy-generation/configuration/basic/#source-file-as-output-file). Types from package-mapped assemblies are imported instead of generated. Plain model interfaces are a separate [CLI output mode](/arc/backend/csharp/proxy-generation/configuration/library-mode/#default-classes-versus-plain-interfaces), not the default and not a replacement for constructors needed by query/identity deserialization. # Validation extraction > The supported subset of backend rules emitted into TypeScript proxies. Generated validators provide early feedback without duplicating simple rules by hand. They are a **projection of a supported subset**, not an equivalent execution of arbitrary backend validation. Keep the server authoritative: missing client rules do not mean a request is valid, and a local validation result does not establish server authorization. ## Prerequisite and extraction flow First define a discoverable [command](/arc/backend/csharp/commands/) or [query](/arc/backend/csharp/queries/). A DTO and validator alone do not create an endpoint or a proxy. Controller-based DTOs are supported when used by a discovered controller endpoint; model-bound types need their normal endpoint declarations. The executable loads the compiled model, finds a matching validator, and instantiates it to inspect its rules. It also reads supported DataAnnotations. For command model/DTO properties and model-bound query parameters, extraction uses this per-member precedence: 1. Extractable rules from the explicit FluentValidation validator take precedence. 2. Direct concept-property/parameter rules are additive, with identical rules deduplicated. 3. DataAnnotations contribute only where neither of the above has contributed rules for that member. The current lookup selects the first matching validator type in the searched assembly. Do not rely on it aggregating several validators for the same model. Controller-based queries use a separate lookup: the generator selects the first DTO whose properties match **all method parameters**, with the same property count, case-insensitive names, and identical types, preferring DTO names containing `Query` (case-insensitive). This lookup includes all method parameters, not just client-facing ones. It extracts rules from the selected DTO, including its supported annotations and concept-property rules. Direct parameter annotations are extracted only if that DTO contributes **no rules at all** (or no DTO matches), rather than as a per-parameter fallback. For example, one DTO rule on `Name` prevents a direct `[Required]` annotation on another parameter from being projected. Direct concept parameters are not independently projected; matching DTO concept properties can contribute rules through DTO extraction. ## FluentValidation rule reference | Backend rule | Emitted TypeScript rule | | --- | --- | | `NotEmpty()` | `notEmpty()` | | `NotNull()` | `notNull()` | | `EmailAddress()` | `emailAddress()` | | `MinimumLength(n)` | `minLength(n)` | | `MaximumLength(n)` | `maxLength(n)` | | `Length(min, max)` | `length(min, max)` | | `Length(n)` | `length(n, n)` | | `Matches(pattern)` | `matches(pattern)` with a JavaScript regular expression | | `GreaterThan(n)` | `greaterThan(n)` | | `GreaterThanOrEqualTo(n)` | `greaterThanOrEqual(n)` | | `LessThan(n)` | `lessThan(n)` | | `LessThanOrEqualTo(n)` | `lessThanOrEqual(n)` | Comparisons require **numeric constant** values. Cross-property comparisons and nonnumeric constants, such as date sentinels, are not projected. DateOnly, DateTime, and DateTimeOffset ordering stays server-side: JavaScript dates lose .NET tick precision, and Arc emits no client clock rule. For date-time inputs that must identify an instant, use [explicit-offset binding](/arc/backend/csharp/commands/input-binding/#require-explicit-date-time-offsets). A .NET regex pattern must also be valid and meaningful in JavaScript; extraction is not a regex-language translation. ## A complete command declaration This C# file can be added to an existing Arc backend with FluentValidation available. It is a minimal response-bearing command, not a persistence example: ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Concepts; using FluentValidation; namespace MyApp.Contacts; public record ContactName(string Value) : ConceptAs(Value) { public static readonly ContactName NotSet = new(string.Empty); } public record ContactAge(int Value) : ConceptAs(Value) { public static readonly ContactAge NotSet = new(0); } public record EmailAddress(string Value) : ConceptAs(Value) { public static readonly EmailAddress NotSet = new(string.Empty); } public class EmailAddressValidator : ConceptValidator { public EmailAddressValidator() => RuleFor(email => email.Value) .NotEmpty().WithMessage("Email address is required") .EmailAddress(); } [Command] public record RegisterContact(ContactName Name, EmailAddress Email, ContactAge Age) { public string Handle() => Name.Value.Trim(); } public class RegisterContactValidator : CommandValidator { public RegisterContactValidator() { RuleFor(contact => contact.Name).NotEmpty().MinimumLength(2).MaximumLength(50); RuleFor(contact => contact.Age).GreaterThanOrEqualTo(18); } } ``` `EmailAddressValidator` owns the email invariant wherever that concept appears; the command validator owns this registration’s name-length and minimum-age rules. Arc’s `CommandValidator` inherits a concept-aware `RuleFor` overload that unwraps `ContactName` and `ContactAge`, so these FluentValidation chains operate on `string` and `int`. Keep each concept in its own file in your application; they are grouped here to make the example self-contained. Build with the [generator configured](/arc/backend/csharp/proxy-generation/getting-started/). A successful checkpoint is a generated `RegisterContact` proxy with an `IRegisterContact` interface and attached `RegisterContactValidator`. The response remains an ordinary string; Chronicle is not involved. This is an **excerpt from its generated validator constructor**, not a separate file to maintain: ```typescript this.ruleFor((c) => c.age).greaterThanOrEqual(18); this.ruleFor((c) => c.email) .notEmpty() .withMessage('Email address is required'); this.ruleFor((c) => c.email).emailAddress(); this.ruleFor((c) => c.name).notEmpty(); this.ruleFor((c) => c.name).minLength(2); this.ruleFor((c) => c.name).maxLength(50); ``` Each extracted rule gets its own statement. Rules with Error severity use the client’s default and emit no `.withSeverity` call; `.WithSeverity(Severity.Warning)` and `.WithSeverity(Severity.Info)` emit `.withSeverity(2)` and `.withSeverity(1)`. Every generated command uses these rule severities: without `[BlockOnValidationSeverity]`, only Error rules block locally; with it, rules at or above the declared severity block. A runtime-dependent `.WithSeverity(...)` delegate cannot be projected faithfully: the generator omits that rule from client validation so it cannot incorrectly block a request, and the server evaluates it on the request. Previously generated proxies without per-rule severities retain their existing Error default. If your application changes FluentValidation’s `ValidatorOptions.Global.Severity`, generated proxies still assume Error for rules without explicit severity: extraction runs at build time, not under the request’s global settings. The command template attaches the validator automatically, so you do not instantiate a second validator in the component. ## DataAnnotations reference | Attribute | Emitted rule | | --- | --- | | `[Required]` | `notEmpty()` | | `[EmailAddress]` | `emailAddress()` | | `[MinLength(n)]` | `minLength(n)` | | `[MaxLength(n)]` | `maxLength(n)` | | `[StringLength(max)]` | `maxLength(max)` | | `[StringLength(max, MinimumLength = min)]` | `length(min, max)` for positive limits | | `[Range(min, max)]` | `greaterThanOrEqual(min)` and `lessThanOrEqual(max)` | | `[RegularExpression(pattern)]` | `matches(pattern)` | | `[Url]` | `url()` | | `[Phone]` | `phone()` | | `[CreditCard]` | `creditCard()` is emitted, but the current client rule builder does **not** implement it | CreditCard validation is not generated correctly `[CreditCard]` currently exposes a generator/client gap: the emitted call fails TypeScript checking against Arc’s current validation API. Do not treat it as supported client validation or manually repair the generated file. Keep server enforcement and account for this limitation before adopting that annotation in a generated endpoint. These mappings are not full attribute-semantic parity. For example, `Required.AllowEmptyStrings` is not projected. Use the numeric two-argument `Range` overload for these examples; the extractor does not interpret the type/string overload as typed limits. Literal `ErrorMessage` values are read, but resource-based message resolution is not reproduced. The following alternative deliberately uses primitive properties to demonstrate annotation extraction at an endpoint boundary. Attributes such as `[Url]` and `[Phone]` target primitive values; do not move them unchanged onto concept objects and assume equivalent server behavior. Prefer named concepts with `ConceptValidator` for normal application-domain contracts, as above. A complete annotation-focused command declaration: ```csharp using System.ComponentModel.DataAnnotations; using Cratis.Arc.Commands.ModelBound; namespace MyApp.Contacts; [Command] public record CheckContact( [property: Required(ErrorMessage = "Name is required")] string Name, [property: Url] string Website, [property: Phone] string PhoneNumber) { public string Handle() => Name; } ``` After generation, its validator constructor contains these statements (an **output excerpt**): ```typescript this.ruleFor((c) => c.name) .notEmpty() .withMessage('Name is required'); this.ruleFor((c) => c.phoneNumber).phone(); this.ruleFor((c) => c.website).url(); ``` `url()` and `phone()` are dedicated client APIs, not the previously documented handwritten regular expressions. ## Messages and deferred factories A literal `.WithMessage("Email address is required")` can be emitted as `.withMessage('Email address is required')`. A deferred `.WithMessage(contact => ...)` factory is **not evaluated or projected**. The rule can still be extracted, with the client rule’s default message. The generator runs at build time, outside the request’s culture, tenant, and ambient state. Evaluating a factory there would freeze the wrong context into the proxy. If exact server-resolved wording is essential, use an appropriate server-only validation rule rather than expecting the client to evaluate a factory. This distinction matters because a blocking client validation result can suppress the request: the server then has no opportunity to resolve a different message. See [frontend validation](/arc/frontend/core/validation/) for rule behavior and severity handling. ## Query and concept validation Model-bound query extraction combines a convention-matched argument model’s validator, direct concept-parameter validators, and parameter annotations using the same precedence rules. The generated type is `QueryNameParameters`, and its validator derives from `QueryValidator`. A standalone `SearchUsersQuery` DTO with a validator does not by itself define a query; connect it through the [query argument-model convention](/arc/backend/csharp/queries/validation/). One-shot queries validate before `perform()`. Observable queries also validate before `subscribe()`; a rejected subscription delivers an invalid `QueryResult` to its callback without opening a connection. For a direct `ConceptAs` property on an extracted command/query DTO, or a direct model-bound query parameter, the primitive representation receives extractable rules from its concept validator. Controller query parameters require a matching DTO concept property as described above. Nullable concept members omit inferred presence rules (`notEmpty` and `notNull`) unless the property or parameter itself has `[Required]`, but retain other inferred rules, such as `maxLength` and `emailAddress`, which accept null or undefined. Explicit model rules and annotations still apply under the precedence rules above. For both model-bound and controller queries, the actual parameter’s optionality governs inferred rules even when its argument model has different nullable-reference annotations. Concepts nested inside deeper object graphs are not recursively projected by this mechanism. See [backend validation](/arc/backend/csharp/commands/validation/) for server enforcement. ## Limitations to account for * `Must`, `MustAsync`, custom validators, and dependency-backed business checks are not translated into browser logic. * Conditional rules (`When`/`Unless`, including `WhenAsync`/`UnlessAsync`, block or chained) are omitted from client validation and enforced only on the server. Unconditional components in the same chain can still be projected. * Rule sets, cascade behavior, runtime-dependent severity, and other FluentValidation execution semantics are not reproduced by the emitted rule statements. Inspect and test generated behavior rather than assuming full server parity. * Nested object/collection validation is not a general object-graph translation. Keep server-only rules in an appropriate server-only form and test boundary values against both client and server. * Constructors run during extraction. A parameterless constructor is preferred; otherwise reference dependencies are passed as `null` and value dependencies receive default values. If construction or reflection fails, extraction can return no FluentValidation rules. A green build is not proof that those rules were emitted. * Do not perform I/O or dereference services while constructing rules. Capturing a service for a deferred server predicate is different from calling it in the constructor. After changing validation, inspect the generated validator and test accepted/rejected values in both layers. Do not manually patch generated files or replace server validation with client checks. # Queries > Read application state once or observe updates with standalone Arc. A screen needs a list, a detail view, or a dashboard—not another hand-written API client. Arc lets you declare the read, expose it through HTTP, and generate a typed TypeScript proxy for the frontend. Queries read **application state**. That can be a MongoDB document, an EF Core entity, data from a service, or an in-memory model. **Arc queries do not require Chronicle or event sourcing.** A database query or MongoDB `Observe()` call is not a Chronicle projection. If your application optionally uses Chronicle, its projected read models can be queried too; see [Chronicle integration](/arc/backend/csharp/chronicle/). ## Choose a declaration style | Style | What you write | Choose it when | | --- | --- | --- | | [Model-bound](/arc/backend/csharp/queries/model-bound/) | Static query method on the `[ReadModel]` type it returns | You want the query next to its data shape with minimal endpoint boilerplate | | [Controller-based](/arc/backend/csharp/queries/controller-based/) | MVC GET action | You need MVC route-value/DTO binding, filters, or HTTP response control | The two paths share result rendering but **not all binding, validation, or authorization behavior**. Start with the model-bound example, then use the controller path deliberately where its contract fits better. ## Request once or observe updates ``` GET / QUERY / subscribe one result streamed results Client Arc endpoint Query method Application state Optional observable producer ``` | Mode | Behavior | Use it for | | --- | --- | --- | | Request/response | Read once through GET or [generated HTTP QUERY](/arc/backend/csharp/queries/using-the-http-query-method/) | One-off reads and reports | | [Observable](/arc/backend/csharp/queries/model-bound/observable-queries/) | Subscribe to a producer over SSE/WebSocket | Lists or dashboards that should stay current | An observable query needs a producer that detects changes; a return type alone does not watch your database. Arc’s MongoDB integration supplies `Observe()`. Other providers need their own observation mechanism. An update may follow a [command](/arc/backend/csharp/commands/) changing database state directly, or an optional Chronicle projection updating it. ## Learn the contract in order 1. [Declare a model-bound read](/arc/backend/csharp/queries/model-bound/), then add [arguments](/arc/backend/csharp/queries/model-bound/query-arguments/). 2. Apply [validation](/arc/backend/csharp/queries/validation/) and [authorization](/arc/backend/csharp/queries/model-bound/authorization/), including their current limitations. 3. Read the [QueryResult contract](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata) and add [paging](/arc/backend/csharp/queries/model-bound/paging/). 4. Expose [observable updates](/arc/backend/csharp/queries/model-bound/observable-queries/) and understand subscription disposal. 5. For advanced delivery, use the [hub protocol](/arc/backend/csharp/queries/observable-query-demultiplexer/), [change streams](/arc/backend/csharp/queries/change-stream/), and [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/). [Read-model interception](/arc/backend/csharp/queries/read-model-interception/) transforms supported result paths, but currently excludes observable HTTP snapshots. [Query health](/arc/backend/csharp/queries/query-health/) helps diagnose hub subscriptions but exposes sensitive telemetry unless you restrict it. Use [cURL workflows](/arc/backend/csharp/queries/using-observable-queries-with-curl/) to distinguish a snapshot from a live stream. ## Consume the read in React A successful backend proxy-generation build supplies typed client declarations. Use the generated query’s hook and result state instead of maintaining a separate transport model. Continue with [queries in React](/arc/frontend/react/queries/) and [proxy generation](/arc/backend/csharp/proxy-generation/). # Observable collection change streams > Hub transfer modes, collection deltas, and their wire contract. ## Transfer modes The observable-query hub can compare collection snapshots with stable item identities and send a `ChangeSet` describing added, replaced, and removed items. This is a transport-level comparison of query results, not a Chronicle event stream or a promise of durable replay. Set `transferMode` on the [hub subscription request](/arc/backend/csharp/queries/observable-query-demultiplexer/). The modes below describe subject-backed **collection** emissions whose items have an `Id` property. Without one, every mode sends full `data` without a change set: | Mode | First emission | Later emissions | | --- | --- | --- | | Omitted (legacy) | Full `data` plus change set (initial items added) | Full `data` plus change set | | `full` | Full `data`, no change set | Full `data`, no change set | | `delta` | Full `data`, no change set | Change set; full `data` is null/omitted | Legacy additive deltas can reduce client reconciliation work, but sending both the snapshot and delta does **not** reduce payload bytes. Delta mode is the bandwidth-saving choice. Single-object and async-enumerable streams are not covered by this collection-delta contract. Direct per-query transports do not automatically inherit hub transfer-mode behavior. ## How changes are identified For items with an `Id` property (case-insensitive), `ChangeSetComputor` compares identity and serialized JSON: * `added`: an identity appears in the new snapshot. * `replaced`: the identity remains but its serialized representation differs. * `removed`: an identity disappears; entries are removed **items**, not merely ID strings. If collection items have no `Id` property, Arc sends the complete current snapshot in `data` without a change set on every emission, even in `delta` mode, and logs one warning per subscription. To get deltas, give each item a stable, unique `Id`. A concept-valued `Id` works too. The public `ChangeSetComputor.ComputeByJson` utility remains available for explicit JSON comparisons, but observable-query transfer does not use that fallback. ``` Previous delivered snapshot Compare identities and JSON Current intercepted snapshot Added / replaced / removed Apply subscription transfer mode Client state ``` ## Wire format Illustrative **payload fragment** for a later delta-mode frame; the surrounding `QueryResult` metadata and hub envelope are intentionally not repeated: ```json { "data": null, "changeSet": { "added": [{ "id": "account-2", "balance": 50 }], "replaced": [{ "id": "account-1", "balance": 120 }], "removed": [{ "id": "account-3", "balance": 0 }] } } ``` When no change set is present, replace client state with `data` as the current snapshot (including after reconnect). A reconnect/replacement subscription starts with a fresh snapshot; a change set is not a resume token. Under delta mode, do not replace client state with null just because the later frame omits full data. ## Backend API `ChangeSet` has `IEnumerable` properties `Added`, `Replaced`, and `Removed`. `ChangeSetComputor` takes `JsonSerializerOptions`; its `Compute(previousItems, currentItems)` returns a change set, and `FindIdentityProperty(Type)` locates the conventional ID property. These APIs compare snapshots you supply; they do not observe a database independently. For database observation, see [MongoDB-backed observable queries](/arc/backend/csharp/queries/model-bound/observable-queries/). For client reconstruction, see [frontend change streams](/arc/frontend/react/queries/change-stream/). # Controller-based queries > Use Arc query results with ASP.NET Core MVC routing and binding. Use a controller when your query needs MVC routing, model binding, or authorization policies. Unlike a [model-bound query](/arc/backend/csharp/queries/model-bound/), the method does not need to live on the type it returns. ## Start with an MVC GET action This controller example uses the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) with an ASP.NET Core Arc host, MVC, and the configured Arc MongoDB provider. Use this `DebitAccount` declaration instead of the model-bound alternative. No Chronicle integration is required. ```csharp using System.Collections.Generic; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; namespace Banking.Accounts; public record DebitAccount(AccountId Id, AccountName Name, decimal Balance); [Authorize(Roles = "AccountReader")] [Route("api/accounts")] public class AccountsController(IMongoCollection collection) : ControllerBase { [HttpGet] public IEnumerable AllAccounts() => collection.Find(_ => true).ToList(); } ``` With authentication and authorization middleware configured, the MVC authorization attribute protects this action. Arc’s `QueryActionFilter` handles **GET** actions: it establishes query context, processes MVC model-state errors, renders the returned data, applies interception, and wraps it in [QueryResult](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata). This is not the model-bound `IQueryPipeline` filter chain. Do not assume model-bound custom filters run for a controller action, or that Arc’s GET query wrapper applies to a POST action. ## Choose the next step * [Route templates](/arc/backend/csharp/queries/controller-based/route-templates/): bind identifiers from route values. * [Query arguments](/arc/backend/csharp/queries/controller-based/query-arguments/): bind scalar values, arrays, and MVC DTOs. * [Dependency injection](/arc/backend/csharp/queries/controller-based/dependency-injection/): resolve application services. * [Return types](/arc/backend/csharp/queries/controller-based/return-types/): understand wrapping, nulls, and explicit MVC responses. * [Paging](/arc/backend/csharp/queries/controller-based/paging/): return `IQueryable` for renderer-based paging. * [Observable queries](/arc/backend/csharp/queries/controller-based/observable-queries/): expose a producer over direct SSE or WebSocket. The [proxy generator](/arc/backend/csharp/proxy-generation/) creates client types from supported controller query declarations. For raw MVC responses, see [without wrappers](/arc/backend/csharp/asp-net-core/without-wrappers/); opting out changes the contract a standard Arc query client expects. # Controller query dependencies > Inject storage, options, and services into MVC query controllers. ## Constructor injection Controllers use ASP.NET Core dependency injection. Inject services through the constructor and keep caller arguments on the action. This alternative banking declaration uses the [shared domain concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) and an ASP.NET Core Arc host with the MongoDB provider configured: ```csharp using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using MongoDB.Driver; namespace Banking.Accounts; public class AccountQueryOptions { public int MaxResults { get; set; } = 100; } public record DebitAccount(AccountId Id, AccountName Name, decimal Balance); [Authorize(Roles = "AccountReader")] [Route("api/accounts")] public class AccountsController( IMongoCollection collection, IOptions options) : ControllerBase { [HttpGet("positive")] public async Task> PositiveAccounts() => await collection.Find(account => account.Balance > 0) .SortBy(account => account.Name) .Limit(Math.Clamp(options.Value.MaxResults, 1, 1000)) .ToListAsync(); } ``` The query is limited before execution. `FindAsync()` returns a cursor; it is too late to call fluent `Limit()` or `Skip()` on that cursor. A fixed cap is not automatic paging and does not provide total-count metadata; use [IQueryable paging](/arc/backend/csharp/queries/controller-based/paging/) when you need it. ## Other providers and lifetimes A registered EF Core `DbContext` or application service works the same way. Database reads do not require Chronicle. Resolve scoped services from the request scope; do not put them in a singleton. Avoid shared cache keys for caller- or tenant-specific results unless the key and invalidation preserve those boundaries. Let genuine storage failures surface as failures instead of returning an empty successful result. For streaming actions, constructor injection alone does not manage an upstream Rx subscription: preserve [subscription-owned teardown](/arc/backend/csharp/queries/model-bound/observable-queries/#subscription-lifetime). Continue with [query arguments](/arc/backend/csharp/queries/controller-based/query-arguments/) for MVC binding and [return types](/arc/backend/csharp/queries/controller-based/return-types/) for the wrapper contract. # Controller-based observable queries > Expose a MongoDB-backed producer through MVC GET streaming. ## Declare a GET action Return a subject from an MVC GET action to make observable handling available. The client/request chooses a snapshot, SSE, or WebSocket; returning `ISubject` does not itself open a connection. This alternative banking declaration uses the [shared domain concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names), an ASP.NET Core Arc host configured with **Cratis.Arc.MongoDB**, MVC authorization, a registered collection, and MongoDB change-stream support: ```csharp using System; using System.Collections.Generic; using System.Reactive.Subjects; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; namespace Banking.Accounts; public record DebitAccount(AccountId Id, AccountName Name, decimal Balance); [Authorize(Roles = "AccountReader")] [Route("api/accounts")] public class AccountsController(IMongoCollection collection) : ControllerBase { [HttpGet("observe")] public ISubject> ObserveAccounts( [FromQuery] decimal minimumBalance = 0) => collection.Observe(account => account.Balance >= minimumBalance); [HttpGet("{id:guid}/observe")] public ISubject ObserveAccount([FromRoute] Guid id) { AccountId accountId = id; return collection.ObserveSingle(account => account.Id == accountId); } } ``` The filter is applied by the database observer. This is standalone Arc database observation, not a Chronicle projection. When no document matches, `ObserveSingle` emits `null` and keeps the subscription open: at the start, after a delete, or after an update moves the document out of the filter. Treat `null` as “not found” on the client. See [model-bound observable queries](/arc/backend/csharp/queries/model-bound/observable-queries/) for the same behavior. ## Keep MVC and model-bound handling distinct Arc’s `QueryActionFilter` handles **GET** actions. Do not change the example to `[HttpPost]` with `[FromBody]` and assume the same streaming adapter runs. Arbitrary `IObservable` values are not enough either: runtime streaming detection recognizes subjects and async-enumerables. MVC authorization protects the controller endpoint. The hub resolves queries by performer name through the query pipeline; a controller route URL is not automatically a hub query name. Use a discovered [model-bound query](/arc/backend/csharp/queries/model-bound/observable-queries/) for the documented [hub subscription protocol](/arc/backend/csharp/queries/observable-query-demultiplexer/), rather than assuming MVC action filters protect a separate hub subscription. ## Compose without leaking Return `collection.Observe()` directly when it expresses the read. MVC uses the same [subscription lifetime and terminal-error contract](/arc/backend/csharp/queries/model-bound/observable-queries/#subscription-lifetime): disposing the returned subscription must release upstream resources, and terminal errors must reach subscribers. Follow that shared checklist when composing streams, including both direct transports’ error and teardown tests. MongoDB `Observe()` handles paging through its query context. A plain subject does not automatically slice emissions; see [observable paging](/arc/backend/csharp/queries/controller-based/paging/#observable-paging). ## Errors and snapshots If query arguments fail validation, a direct WebSocket or SSE request receives one `QueryResult` with `isValid: false`, the validation results, and the correlation ID before the connection closes; a plain HTTP request still receives HTTP 400 with the query result. Monitor provider logs and stream health: these MongoDB producers log watcher failures and complete/dispose rather than forwarding them through `OnError`. See [provider failures](/arc/backend/csharp/queries/model-bound/observable-queries/#paging-and-failures) for the distinction from per-change and downstream operator errors. For a one-shot MongoDB read, use `waitForFirstResult=true`. A no-wait GET returns 202 without subscribing or disposing the subject and can retain an eagerly started watcher. The shared [snapshot guidance](/arc/backend/csharp/queries/model-bound/observable-queries/#waiting-for-the-first-http-result) explains current-value requirements, cleanup, and timeouts. See [cURL workflows](/arc/backend/csharp/queries/using-observable-queries-with-curl/) for requests you can run. Caution Observable HTTP snapshots currently bypass [read-model interception](/arc/backend/csharp/queries/read-model-interception/). Keep unauthorized fields and rows out of the producer’s output rather than relying only on streaming interception to mask them. ## Ongoing authorization An initial authorized subscription does not automatically end when permissions change. Use [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/) for per-emission revocation checks and test disconnect cleanup. The [React observable-query APIs](/arc/frontend/react/queries/observable-queries/) manage client subscription lifecycle; reconnecting establishes a new read, not durable replay. # Controller query paging > Let the MVC query renderer apply paging and sorting to IQueryable results. ## Return an IQueryable For a default Arc-wrapped MVC GET, return an unmaterialized database query. This alternative banking declaration uses the [shared domain concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names), an ASP.NET Core Arc host, configured authorization, and the MongoDB provider: ```csharp using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; using MongoDB.Driver.Linq; namespace Banking.Accounts; public record DebitAccount(AccountId Id, AccountName Name, decimal Balance); [Authorize(Roles = "AccountReader")] [Route("api/accounts")] public class AccountsController(IMongoCollection collection) : ControllerBase { [HttpGet] public IQueryable AllAccounts() => collection.AsQueryable().OrderBy(account => account.Id); [HttpGet("positive")] public IQueryable PositiveAccounts() => collection.AsQueryable().Where(account => account.Balance > 0) .OrderBy(account => account.Id); } ``` ```http GET /api/accounts?page=0&pageSize=25 GET /api/accounts/positive?page=1&pageSize=10&sortBy=name&sortDirection=asc ``` The action filter establishes the paging/sorting context. `QueryableQueryRenderer` counts the filtered query, applies requested sorting, then applies `Skip`/`Take`. A database-backed LINQ provider can perform this work server-side; an in-memory queryable cannot undo earlier materialization. ## Paging contract `page` is zero-based, `pageSize` is the requested size, and `sortBy`/`sortDirection` select the sort. GET matches these reserved keys case-insensitively, so `sortby` and `SORTBY` also work. Use positive, bounded sizes and a stable ordering. Client-requested ordering replaces the method’s primary ordering; account for duplicate sort values in production. The response `paging` object has **`page`, `size`, `totalItems`, `totalPages`**. See [query result metadata](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata). Without a paging request, the query is not automatically capped. Lists, arrays, and plain enumerable results are not automatically sliced. A manually paged list does not automatically carry the database’s total count. Do not return a `QueryResult` from a wrapped action to fix that: it becomes nested data, not outer metadata. ## Manual control `IQueryContextManager.Current` exposes `Paging` and `Sorting` if an integration needs them. When using MongoDB’s fluent API, compose `Skip`, `Limit`, and `Sort` **before** execution; use expression sorting or a `SortDefinition`, not `SortBy(string)`. For a completely custom response/metadata contract, use `[AspNetResult]` and supply the full MVC response yourself. That is an opt-out from Arc wrapping and needs a matching client. Prefer the queryable path unless you need that control. ## Observable paging A stream does not become paged merely because it emits a collection. Arc’s MongoDB `Observe()` supplies [provider-aware paging](/arc/backend/csharp/queries/model-bound/paging/#observable-queries-with-paging); arbitrary subjects must implement it themselves. See [controller observable queries](/arc/backend/csharp/queries/controller-based/observable-queries/). # Controller query arguments > Bind MVC route values, query strings, collections, and validated DTOs. ## MVC binding is a separate contract Controller GET actions use ASP.NET Core model binding, not Arc’s scalar model-bound query readers. Use `[FromRoute]`, `[FromQuery]`, and `[FromHeader]` to make each source explicit. MVC can bind arrays and DTO properties from query strings; this does not imply a static `[ReadModel]` method supports those same HTTP input shapes. ## Bind and validate a search DTO This alternative banking example uses the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) and an ASP.NET Core Arc host with MVC, authorization, and the MongoDB provider configured. `Prefix` is search text—not a complete account name—so it remains a string at the request boundary: ```csharp using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Bson; using MongoDB.Driver; namespace Banking.Accounts; public record DebitAccount(AccountId Id, AccountName Name, decimal Balance); public class AccountSearch { [Required] [StringLength(50, MinimumLength = 3)] public string Prefix { get; set; } = string.Empty; [Range(0, 1000000)] public decimal MinimumBalance { get; set; } } [Authorize(Roles = "AccountReader")] [Route("api/accounts")] public class AccountsController(IMongoCollection collection) : ControllerBase { [HttpGet("search")] public IEnumerable Search([FromQuery] AccountSearch query) { var prefix = new BsonRegularExpression("^" + Regex.Escape(query.Prefix)); var filter = Builders.Filter.Regex(account => account.Name, prefix) & Builders.Filter.Gte(account => account.Balance, query.MinimumBalance); return collection.Find(filter).SortBy(account => account.Name).Limit(100).ToList(); } [HttpGet("by-ids")] public IEnumerable ByIds([FromQuery] Guid[] ids) { var accountIds = ids.Select(id => (AccountId)id); return collection.Find(Builders.Filter.In(account => account.Id, accountIds)).ToList(); } } ``` ```http GET /api/accounts/search?prefix=Sav&minimumBalance=100 GET /api/accounts/by-ids?ids=11111111-1111-1111-1111-111111111111&ids=22222222-2222-2222-2222-222222222222 ``` The GUID array is an explicit MVC wire boundary; the action converts it to domain `AccountId` values before querying. The prefix filter targets the stored account-name field and escapes the caller’s search text rather than interpreting it as a regular expression. The validation attributes in this example belong to **MVC DTO properties**. Arc’s GET action filter consults MVC model state before invoking the action. `[ApiController]` and custom MVC filters can add their own earlier validation responses; do not assume all configurations return the identical envelope. A filter on caller-supplied IDs is not owner authorization. This example deliberately requires a role allowed to read all matching accounts. ## Defaults, sorting, and paging Nullable/defaulted scalar action arguments support optional input according to MVC’s binding rules. For Arc-generated controller query endpoints, invalid supplied scalars, concepts, and collection elements are rejected with HTTP 400 and a malformed-request validation error naming the argument and its type for both GET and QUERY; the action does not run. This includes nullable and defaulted concept parameters when an invalid value is supplied, rather than treating that value as missing. MVC’s own model-binding responses can differ. For Arc paging, prefer returning `IQueryable` and using [the paging context](/arc/backend/csharp/queries/controller-based/paging/) instead of slicing twice. If composing a MongoDB fluent query yourself, use expression-based sorting such as `SortBy(account => account.Name)`, or a `Builders.Sort` definition passed to `Sort(...)`. There is no `SortBy(string)` overload. Constrain client-selectable sort fields to your intended public fields. ## Request body arguments An ordinary MVC action can use `[FromBody]` and a suitable verb for structured JSON. That is **not** a drop-in Arc GET observable query: `QueryActionFilter` runs for GET, and the standard Arc POST command surface has a different contract. Do not put `[HttpPost]` on a subject-returning action and assume Arc will stream it. For model-bound one-shot queries needing a body, see [HTTP QUERY](/arc/backend/csharp/queries/using-the-http-query-method/); its body envelope still uses scalar argument conversion, not MVC DTO deserialization. Choose and test the endpoint/client contract explicitly rather than treating these paths as interchangeable. Continue with [route templates](/arc/backend/csharp/queries/controller-based/route-templates/) for path parameters and [validation](/arc/backend/csharp/queries/validation/) for the model-bound/MVC distinction. # Controller query return types > Understand MVC data results, Arc wrapping, observable values, and null handling. ## Data return types Arc’s default MVC GET handling wraps returned data in its nongeneric `QueryResult`. Unlike model-bound discovery, MVC does not require the action to live on the returned type. | Return value | Default Arc GET behavior | | --- | --- | | A model or custom summary | Wrapped as `data` | | A list, array, or materialized enumerable | Wrapped collection; no built-in slicing | | `IQueryable` | Renderer applies requested paging/sorting before serialization | | `Task` | MVC awaits the task before Arc processes its data | | Runtime `ISubject` or `IAsyncEnumerable` | Streaming adapter chooses handling from the request | A MongoDB `Find(...)` is fluent query construction; call `ToList()`/`ToListAsync()` to return materialized data. See [dependency injection](/arc/backend/csharp/queries/controller-based/dependency-injection/) for a complete async action. ## Query results Do **not** return `QueryResult` from an ordinary wrapped controller GET to control the outer metadata. The action filter creates another `QueryResult` and treats the returned wrapper as data, producing a nested contract. A custom envelope is likewise ordinary data, not an instruction to replace Arc’s outer envelope. Prefer [IQueryable paging](/arc/backend/csharp/queries/controller-based/paging/). If you need complete control over status and body, opt out using `Cratis.Arc.AspNetResultAttribute` and return a conventional MVC result. Opting out also skips Arc query rendering/interception; you own the entire response contract and must use a compatible client. ## Nullable return types A null response from a default Arc-wrapped MVC GET becomes a failed result with `exceptionMessages` containing `"Null data returned"`; it is not ordinary successful absence or an automatic 404. This differs from the [model-bound pipeline](/arc/backend/csharp/queries/model-bound/return-types/#collections-and-absence), where null can remain a success. Choose deliberately: * An empty collection means a successful collection with no matches. * A non-null application response model can represent absence explicitly inside its data contract. * `[AspNetResult]` with `NotFound()` provides ordinary MVC 404 semantics. The [route example](/arc/backend/csharp/queries/controller-based/route-templates/#bind-a-route-value) shows the complete action. Do not convert database exceptions into null/empty successful results. ## Observable return types Return `ISubject` for a subject-backed query. An arbitrary Rx `IObservable` is not sufficient: the controller adapter checks the **runtime value** for subject/async-enumerable support. Merely declaring `IObservable` does not adapt a `Select(...)` result into a subject. The return type does not establish a WebSocket; the request selects the transport. See [controller observable queries](/arc/backend/csharp/queries/controller-based/observable-queries/) for a complete GET action and subscription lifetime rules. # Controller query route templates > Define explicit MVC routes and bind resource identifiers from the path. ## Bind a route value MVC combines the controller’s `[Route]` with the action’s `[HttpGet]` template. Unlike model-bound `[Path]` queries, MVC reads values from route placeholders. This alternative banking example uses the [shared domain concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) and `[AspNetResult]` so its not-found response has ordinary MVC 404 semantics instead of Arc’s null-result failure. It requires an ASP.NET Core Arc host, configured authorization, and the MongoDB provider. ```csharp using System; using System.Threading.Tasks; using Cratis.Arc; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; namespace Banking.Accounts; public record DebitAccount(AccountId Id, AccountName Name, decimal Balance); [Authorize(Roles = "AccountReader")] [Route("api/accounts")] public class AccountsController(IMongoCollection collection) : ControllerBase { [AspNetResult] [HttpGet("{id:guid}", Name = "AccountById")] public async Task> ById([FromRoute] Guid id) { AccountId accountId = id; var account = await collection.Find(candidate => candidate.Id == accountId).FirstOrDefaultAsync(); if (account is null) { return NotFound(); } return Ok(account); } } ``` `GET /api/accounts/11111111-1111-1111-1111-111111111111` binds a GUID at the HTTP boundary. The action converts it to `AccountId` before the domain-specific query. The action returns a raw account or 404, not a `QueryResult`. Use a client expecting that raw contract; see [without wrappers](/arc/backend/csharp/asp-net-core/without-wrappers/). ## Template reference These are **route-template fragments**, not independent controller declarations: | Template | Use | | --- | --- | | `api/accounts` on the controller, `{id}` on the action | A resource under the controller path | | `{id:guid}` | Match only GUID-shaped route values | | `{id:int}` | Match only integer-shaped route values | | `category/{category?}` | Optional final segment; give the action argument a compatible default | | `~/api/account-summary` | Override rather than append to the controller path | | `api/[controller]` | Substitute the MVC controller name | | `[action]` | Substitute the action name | Constraints choose which endpoint matches; they are not a replacement for business validation or authorization. A nested path such as `/customers/{customerId}/accounts/{accountId}` does not prove ownership. Check the authenticated caller’s access and apply both identifiers in the actual database predicate. ## Choose route or query string Use route values for resource identity and query strings for filters. Keep the predicate consistent with the declared inputs: do not accept a category/date/filter and then return an unfiltered collection. For the simpler static-query alternative, use [model-bound arguments and explicit paths](/arc/backend/csharp/queries/model-bound/query-arguments/#url-binding). For MVC collection/DTO binding, continue with [query arguments](/arc/backend/csharp/queries/controller-based/query-arguments/). # Model-bound queries > Keep a query with the read model it returns, without writing a controller. When a screen needs a list of accounts, you should not need a controller just to forward a database call. Put a static method on the returned model and mark the model with `[ReadModel]`. Arc discovers the query and generates its HTTP endpoint and TypeScript proxy. A read model is the shape you serve. It can come from MongoDB, EF Core, a service, or in-memory data. **Neither `[ReadModel]` nor a database query requires Chronicle or event sourcing.** ## Model account identities and names Cratis applications conventionally give domain values their own types. `AccountId` says which kind of identity a method accepts; `AccountName` is not interchangeable with an address or another string. The C# compiler can catch accidental swaps before the application runs. Define shared concepts once in the feature and reuse them in its commands and read models: ```csharp using System; using Cratis.Concepts; namespace Banking.Accounts; public record AccountId(Guid Value) : ConceptAs(Value) { public static readonly AccountId NotSet = new(Guid.Empty); public static AccountId New() => new(Guid.NewGuid()); public static implicit operator AccountId(Guid value) => new(value); } public record AccountName(string Value) : ConceptAs(Value) { public static readonly AccountName NotSet = new(string.Empty); public static implicit operator AccountName(string value) => new(value); } ``` These are standalone domain concepts, not Chronicle event-source identities. Arc and its MongoDB integration handle their underlying wire/storage values, and the proxy generator maps them to the corresponding TypeScript value types. The C# domain distinctions are not automatically branded TypeScript types. This is a modeling convention, not a requirement imposed by `[ReadModel]`. Add a `ConceptValidator` when an invariant should follow a value wherever Arc validates it. See [concepts and serialization](/arc/backend/csharp/mongodb/concepts/) and the [validation tutorial](/arc/tutorial/validation/). ## Start with one read Use the concepts above in an Arc host with [the MongoDB provider configured](/arc/backend/csharp/mongodb/getting-started/) and an `IMongoCollection` available from DI. The following is the complete read-model declaration, not a complete host. Later pages show alternative declarations of this same model; do not add all of them as duplicate types. ```csharp using System.Collections.Generic; using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; namespace Banking.Accounts; [ReadModel] public record DebitAccount(AccountId Id, AccountName Name, decimal Balance) { [Path("/api/accounts")] public static IEnumerable AllAccounts(IMongoCollection collection) => collection.Find(_ => true).ToList(); } ``` `GET /api/accounts` now returns a [query result envelope](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata) whose `data` is the account list. The explicit path makes this example independent of namespace-routing configuration. `AllAccounts` becomes the generated query class name; its fully qualified query name is `Banking.Accounts.DebitAccount.AllAccounts`. This unprotected teaching example is suitable only for public data or a local sandbox. Before exposing account data, add [authorization](/arc/backend/csharp/queries/model-bound/authorization/). ## What Arc discovers Use a non-generic static method returning **the declaring read-model type**, a collection of that type, or one of its [supported wrappers](/arc/backend/csharp/queries/model-bound/return-types/). Public methods are the recommended declaration style. A method accepting that exact read-model type as a parameter is never a query, even if the parameter is nullable. This keeps self-reduction and other self-parameter helpers off the endpoint surface without requiring `RequireQueryAttribute`. For example, a method on `DebitAccount` returning `IEnumerable` qualifies. A method there returning `AccountSummary`, `int`, or `PagedResult` does not. Put a summary query on an `[ReadModel] AccountSummary` instead, or choose a [controller](/arc/backend/csharp/queries/controller-based/). Compiling a method is not proof that Arc discovers an endpoint for it. `Task` can wrap a supported result, including `ISubject`. Query dependencies are method parameters resolved by type; caller arguments are the other parameters. See [static methods](/arc/backend/csharp/queries/model-bound/static-methods/) and [dependency injection](/arc/backend/csharp/queries/model-bound/dependency-injection/). ## Grow the query in order 1. Add [scalar arguments](/arc/backend/csharp/queries/model-bound/query-arguments/) and [validation](/arc/backend/csharp/queries/validation/). 2. Protect the query with [Arc authentication and role checks](/arc/backend/csharp/queries/model-bound/authorization/); do not assume ASP.NET policies run in the model-bound evaluator. 3. Return `IQueryable` for [paging and sorting](/arc/backend/csharp/queries/model-bound/paging/). 4. Return a supported stream for [observable updates](/arc/backend/csharp/queries/model-bound/observable-queries/). 5. Consume the generated proxy in [React queries](/arc/frontend/react/queries/). # Model-bound query authorization > Protect reads with authentication, roles, named policies, and selected schemes. ## Choose the right authorization surface For model-bound queries, use attributes from **`Cratis.Arc.Authorization`**. The default evaluator checks whether the current principal is authenticated and, when roles are specified, belongs to at least one of those roles. Register a native scoped policy using [Core named policies](/arc/backend/csharp/core/authorization/#register-a-named-policy), then put `[Authorize(Policy = "ActiveSubscription")]` on the read model or a query method. Arc awaits every named policy before invoking the method, whether the call comes from HTTP, the query pipeline, or a new hub subscription. Unknown and duplicate policies fail host startup. Method-level declarations replace type-level declarations, while stacked attributes on one declaration all apply. The selected principal must be authenticated, even when the policy itself would allow anonymous access. `AuthorizationPolicyContext.ReceivedAt` matches the executing `QueryContext.ReceivedAt` even when a scheme selects a fresh scope or authorization is evaluated repeatedly. Arc captures a new receipt for each hub subscribe operation, not for the WebSocket or SSE connection; see [query context](/arc/backend/csharp/queries/query-pipeline/#query-context-and-renderers). On ASP.NET Core, both Arc and Microsoft’s authorization attributes can name ASP.NET policies or select registered authentication schemes. Arc authenticates an explicitly requested or policy-contributed scheme instead of trusting the default request identity; on SSE it uses the live subscribe POST. Scheme-selected queries construct authorization filters and later query dependencies under the same selected principal and tenant in a fresh Arc-owned service scope. A direct `IQueryPipeline.Perform(..., serviceProvider)` call with an arbitrary provider cannot safely rebind already-resolved identity-dependent services and fails closed if it selects a different identity; use mapped HTTP or a hub subscription. The standalone Core host rejects `AuthenticationSchemes` at startup (see [ARC0021](/arc/backend/csharp/code-analysis/#arc0021-unevaluated-authorization-settings)). ASP.NET Core middleware/MVC authorization remains a separate HTTP boundary. Do not substitute input validation for an authorization verdict. ## Require a role Use the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) with an authenticated host and the configured MongoDB provider. This replaces the earlier `DebitAccount` declaration: ```csharp using System.Collections.Generic; using Cratis.Arc.Authorization; using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; namespace Banking.Accounts; [ReadModel] [Roles("AccountReader")] public record DebitAccount(AccountId Id, AccountName Name, decimal Balance) { [Path("/api/accounts")] public static IEnumerable AllAccounts(IMongoCollection collection) => collection.Find(_ => true).ToList(); [Roles("Admin", "Auditor")] [Path("/api/accounts/overdrawn")] public static IEnumerable OverdrawnAccounts(IMongoCollection collection) => collection.Find(account => account.Balance < 0).ToList(); } ``` `AllAccounts` requires `AccountReader`. `OverdrawnAccounts` requires **Admin or Auditor**, replacing the type-level requirement rather than adding to it. These roles intentionally grant access to all matching accounts; this example does not claim owner-only access. ## Attribute precedence For Arc’s built-in attributes: 1. Method-level `[AllowAnonymous]` permits anonymous access. 2. Method-level `[Authorize]` or `[Roles]` takes precedence over the declaring type’s requirements. 3. Otherwise type-level anonymous/authorization requirements apply. 4. Without a requirement, the default evaluator permits access. Do not combine `[AllowAnonymous]` with `[Authorize]`/`[Roles]` on the same target, from either attribute family: the evaluator rejects that combination as ambiguous, and analyzer [ARC0019](/arc/backend/csharp/code-analysis/#arc0019-conflicting-authorization) reports it at build time. Use `[Authorize]` for authentication alone and `[Roles("Admin", "Auditor")]` for authentication plus any listed role. A denied model-bound query does not invoke its method and produces `isAuthorized: false`. Direct HTTP normally maps that verdict to 403; hub denial is an `Unauthorized` message. ## Ownership is a separate rule A role check does not establish record ownership. Neither a balance predicate nor a non-null user ID is an ownership check. If a read is owner-scoped, derive the owner from the authenticated principal’s trusted identity mapping and constrain the database predicate to that owner **and** the requested record ID. Handle absent/unmapped identities by denying access, never by dropping the owner predicate. There is no universal claim-to-owner mapping Arc can supply for your application. This page therefore does not provide a success-shaped ownership-policy stub. Test at least two owners, a missing identity, and an unauthorized caller through every exposed route/transport before using such a query for private data. Generated proxies provide type safety, not client-side security enforcement. ## Observable authorization Authorization gates each new subscription. Direct SSE/WebSocket streams keep the live request’s selected principal, tenant, and service provider through each emission. Emissions from another producer’s execution flow restore the subscriber’s identity (including on controller-based streams without policy metadata). On disconnect, direct streams stop accepting emissions and wait for in-flight interception and guards before releasing their request resources; a normally completed stream delivers already accepted emissions before ending. The hub snapshots the selected principal, typed arguments, and tenant for subsequent emissions, so guards run with the admitting identity even after the subscribe POST ends. Hub emissions have no native ASP.NET Core `HttpContext` after admission: a subscribe POST has ended and an emission can originate under another request’s identity. Resolve identity through `ICurrentPrincipalAccessor` or the Arc emission context rather than native HTTP inside a hub emission guard. It does not automatically revoke a running stream when roles change or credentials expire. Use an [emission guard](/arc/backend/csharp/queries/observable-query-emission-guards/) backed by your current session/permission state when revocation must affect ongoing delivery. Do not use [interceptor masking](/arc/backend/csharp/queries/read-model-interception/) as your only data-access control: observable HTTP snapshots currently bypass that interception path. Ensure the producer itself never yields fields or rows the caller must not receive. # Query dependency injection > Distinguish injected services from caller arguments in static queries. ## How parameters are classified Model-bound queries inject services as method parameters. A registered **reference type** can be classified as a dependency, except for concepts (`ConceptAs`), which are always caller-supplied arguments even when registered. Value types also remain caller arguments. The performer resolves dependencies from the service provider supplied to the query pipeline. A `CancellationToken` parameter is neither: Arc binds it to the request’s abort token, as ASP.NET Core does for an endpoint handler, so the query can stop when the client disconnects. It is not a query argument and does not appear in the generated TypeScript proxy. Do not register a query-input DTO as a service and expect HTTP to populate it. A registered reference type can become an injected dependency instead. Use [scalar arguments](/arc/backend/csharp/queries/model-bound/query-arguments/) for the built-in HTTP readers and keep service interfaces distinct from input types. ## Inject only what the read needs This alternative `DebitAccount` declaration uses the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names). Register its collection through the Arc MongoDB provider. `ILogger` and `IOptions` use ordinary .NET DI. This database read is standalone Arc, not a Chronicle projection. ```csharp using System.Collections.Generic; using System.Threading.Tasks; using Cratis.Arc.Queries.ModelBound; using Microsoft.Extensions.Options; using MongoDB.Driver; namespace Banking.Accounts; public class AccountQueryOptions { public int MaxResults { get; set; } = 100; } [ReadModel] public record DebitAccount(AccountId Id, AccountName Name, decimal Balance) { [Path("/api/accounts/positive")] public static async Task> PositiveAccounts( IMongoCollection collection, IOptions options) { return await collection.Find(account => account.Balance > 0) .SortBy(account => account.Name) .Limit(System.Math.Clamp(options.Value.MaxResults, 1, 1000)) .ToListAsync(); } } ``` The limit is applied before database execution. It is a fixed result cap, not automatic paging. Use [an `IQueryable` result](/arc/backend/csharp/queries/model-bound/paging/) when the client needs pages and total counts. The same injection pattern works with an application’s registered EF Core `DbContext` or service interface. A summary computed by such a service still needs its query method on the returned summary read model; DI does not relax [discovery rules](/arc/backend/csharp/queries/model-bound/return-types/). ## Lifetimes and failures Use the lifetime appropriate to the service. Do not capture scoped services in singleton caches or cache user-specific results under a process-wide key. If you cache results, include every relevant tenant, caller/permission boundary, and argument in the key and define invalidation explicitly. For long-lived streams, the disposal of the subscription—not merely the return of the method—must release upstream resources. See [observable query lifetime](/arc/backend/csharp/queries/model-bound/observable-queries/#subscription-lifetime). A missing dependency or database failure is a failure, not an empty successful result. Test dependency resolution through the real host as well as calling the static method directly; a direct method call alone does not verify discovery or DI classification. # Model-bound observable queries > Watch database state with subscription-owned streams and explicit transport choices. A page that should stay current needs a producer of updates, not repeated manual refreshes. An observable query exposes that producer through Arc. The client chooses a snapshot, direct SSE/WebSocket, or a [multiplexed hub subscription](/arc/backend/csharp/queries/observable-query-demultiplexer/); a return type alone does not establish a connection. ## Basic observable query This alternative declaration uses the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names), an Arc host configured with **Cratis.Arc.MongoDB**, a registered collection, and MongoDB change-stream support (a replica set or sharded deployment). `Observe()` lives in the `MongoDB.Driver` namespace. This is ordinary database observation in standalone Arc, not a Chronicle projection. ```csharp using System.Collections.Generic; using System.Reactive.Subjects; using Cratis.Arc.Authorization; using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; namespace Banking.Accounts; [ReadModel] [Roles("AccountReader")] public record DebitAccount(AccountId Id, AccountName Name, decimal Balance) { [Path("/api/accounts/observe")] public static ISubject> ObserveAccounts( IMongoCollection collection, decimal minimumBalance = 0) => collection.Observe(account => account.Balance >= minimumBalance); [Path("/api/accounts/observe-single")] public static ISubject ObserveAccount( AccountId id, IMongoCollection collection) => collection.ObserveSingle(account => account.Id == id); } ``` ### When There Is No Matching Document `ObserveSingle()` and `ObserveById()` emit `null` — not an error, and not a completed observable — when there is no document to report: the document was deleted, an update moved it out of the filter, or the initial query never found one. The subscription stays open, so if a document with the same key reappears later, subscribers start receiving it again. This is also what happens when a `[ReadModel]` marked `[RemovedWith]` is removed: removal hard-deletes the backing document, and an active subscriber sees that removal as this same `null` emission — “the read model was removed” is not a special case to handle separately. Guard against it on the frontend the same way you guard against “not loaded yet” — with `result.hasData` (or `result.isReady` if you need to tell “no result yet” apart from “ready, but nothing matches”): ```tsx // ObserveAccount is the generated proxy for the query method above const [result] = ObserveAccount.use({ id: accountId }); if (!result.isReady) { return ; } if (!result.hasData) { return ; } return ; ``` See [Observing Collections](/arc/backend/csharp/mongodb/observing-collections/#when-the-observed-document-is-gone) for the MongoDB-level detail. The method must still return the declaring read model or a supported wrapper around it. `Task>` is allowed; arbitrary `IObservable` is not a model-bound discovery shape. See [return types](/arc/backend/csharp/queries/model-bound/return-types/). For hub collection subscriptions, each item needs a stable, unique `Id` property to receive change sets (including in `delta` mode). An `Id` concept like `AccountId` in the example works. Without an `Id`, Arc sends the complete collection on every emission without a change set and logs a warning once per subscription. See [collection change streams](/arc/backend/csharp/queries/change-stream/) for transfer modes. ## Custom observable logic Return the provider subject directly when it expresses the read. Rx operators such as `Select`, `CombineLatest`, or `Sample` produce observables; adapting them to Arc’s subject contract must preserve both [subscription lifetime and terminal errors](#subscription-lifetime). This keeps derived streams safe to disconnect, not just able to forward values. ## Subscription lifetime Direct transports dispose the subscription they receive; the hub also manages per-subscription resources. A composition must therefore honor two contracts: * **Subscription-owned teardown:** the disposable returned by `Subscribe` must release every upstream subscription. Keep the disposable from `.Subscribe(replaySubject)`; implementing `IDisposable` on a wrapper alone does not connect it to downstream teardown. * **Terminal error propagation:** deliver errors to downstream subscribers and release upstream resources. Direct SSE and WebSocket transports send a failed query result with the subscription’s correlation id and exception message (without a stack trace) before ending a stream whose source calls `OnError`, unless the subscription was already canceled. Direct SSE and WebSocket transports can call the returned subject’s `OnError` after interception or delivery failures. An onNext-only adapter uses a default throwing error handler; explicitly preserve the terminal-error path rather than swallowing it. Test interception failures through both direct transports, terminal error delivery, and repeated subscribe/disconnect cycles. Verify watcher counts return to baseline. ## Paging and failures MongoDB `Observe()` reads the Arc query context and performs [provider-specific paging](/arc/backend/csharp/queries/model-bound/paging/#observable-queries-with-paging). Arbitrary subjects do not get automatic per-emission slicing. For the collection `Observe()` and `ObserveSingle()` producers above, MongoDB watcher failures are **logged and followed by completion/disposal**, not forwarded through `OnError`. Per-change processing failures are logged separately and the watcher may continue. Therefore `Do(onError: ...)` cannot guarantee notification of these source failures. Downstream Rx operator errors are a separate error channel. Monitor provider logs and stream health; completion does not prove that the data is current. ## Waiting for the first HTTP result For MongoDB snapshots, use `waitForFirstResult=true` so Arc subscribes, receives the first emission, and disposes that subscription. Waiting defaults to 30 seconds; a positive `waitForFirstResultTimeout` overrides it in seconds, and timeout returns 408. A no-wait snapshot neither subscribes nor disposes the subject. MongoDB’s `LifetimeAwareSubject` has no readable `Value` property, even after buffering an emission, so this request returns 202 with `isReady: false`. Because `Observe()` starts its watcher eagerly, it can leave that watcher running. Reserve no-wait snapshots for producers with a readable current value and verified cleanup on the no-subscription path. ```bash curl --include 'https://localhost:5001/api/accounts/observe?waitForFirstResult=true&waitForFirstResultTimeout=10' ``` Use a trusted local certificate and your application’s normal authentication for this protected example. This request waits for a first emission, **not a change since the previous request**. See [cURL workflows](/arc/backend/csharp/queries/using-observable-queries-with-curl/) for SSE and bounded repeated polling. Caution Observable HTTP snapshots currently bypass [read-model interception](/arc/backend/csharp/queries/read-model-interception/). Produce only data the caller is allowed to receive; do not depend on streaming-only masking to protect a snapshot endpoint. ## Testing observable queries Use [query scenarios](/arc/backend/csharp/testing/query-scenario/#observable-queries) to check a subject’s first value with `Perform`, or its initial and subsequent emissions with `Observe` and `QueryObservation.Next`. The scenario applies per-emission interception and guards without a hosted transport. MongoDB observation still requires a real MongoDB provider; use hosted integration specs for SSE/WebSocket delivery and database watcher behavior. ## Authorization and frontend integration [Authorization](/arc/backend/csharp/queries/model-bound/authorization/) runs when subscribing. Use [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/) when permissions must be rechecked during delivery. Generated proxies and [React observable hooks](/arc/frontend/react/queries/observable-queries/) manage client subscriptions; reconnecting creates a new subscription, not a durable event-log replay. # Model-bound query paging > Use IQueryable for one-shot paging and provider-aware observation for live pages. ## Return the query, not all its rows To let Arc request one page from your database, return `IQueryable` without materializing it first. This alternative declaration uses the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) and the configured Arc MongoDB provider: ```csharp using System.Linq; using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; using MongoDB.Driver.Linq; namespace Banking.Accounts; [ReadModel] public record DebitAccount(AccountId Id, AccountName Name, decimal Balance) { [Path("/api/accounts")] public static IQueryable AllAccounts(IMongoCollection collection) => collection.AsQueryable().OrderBy(account => account.Id); } ``` ```http GET /api/accounts?page=0&pageSize=25 GET /api/accounts?page=1&pageSize=10&sortBy=name&sortDirection=asc ``` The first request uses the method’s stable ID ordering. A client-requested sort replaces that primary ordering; design a stable ordering for production paging, especially when a sort field has duplicates. ## How it works The built-in `QueryableQueryRenderer` counts the filtered query, applies requested sorting, and then appends `Skip(page * pageSize)` and `Take(pageSize)`. The provider determines whether these operations execute in the database. An in-memory `AsQueryable()` cannot undo earlier database materialization. | Request key | Meaning | | --- | --- | | `page` | Zero-based index; defaults to zero when `pageSize` is supplied | | `pageSize` | Enables GET paging when parsed as an integer | | `sortBy` | Read-model field to sort by | | `sortDirection` | `asc` or `desc`; provide it with `sortBy` | GET matches these four reserved keys case-insensitively, including `sortby` and `SORTBY`. Use valid nonnegative page indices and positive, bounded sizes. With no paging request, Arc returns the full matching result. Paging alone is not a server-enforced result cap. These are context keys, **not method parameters** in model-bound GET. Do not declare `int page` or `int pageSize` and expect normal binding. See [reserved keys](/arc/backend/csharp/queries/model-bound/query-arguments/#reserved-keys). ## Result metadata The response `paging` object contains `page`, `size`, `totalItems`, and computed `totalPages`. It does not contain `pageSize`, `hasNext`, or `hasPrevious`. See the complete [query result contract](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata). Materialized lists, arrays, and plain enumerable results do not receive built-in slicing/sorting. Returning a manually constructed `QueryResult` from the read model is not a metadata-control workaround and fails the ordinary discovery contract. ## Observable queries with paging `ISubject>` describes a stream, not a paging implementation. The streaming transport attaches paging metadata but does **not** slice arbitrary emissions. Arc’s MongoDB `collection.Observe()` is provider-aware: it captures `IQueryContextManager.Current`, applies sorting/paging to the database query, and updates the total count as it watches changes. The [observable example](/arc/backend/csharp/queries/model-bound/observable-queries/) therefore supports live pages when called through an Arc query context. A plain `Subject>` emitting 100 items still emits 100 items when the request asks for 10, unless its producer implements paging. Keep database observation separate from optional Chronicle projection/subscription behavior; neither MongoDB queries nor `Observe()` requires Chronicle. ## Frontend integration Use the generated proxy’s paging API rather than manually changing its data array. See [React paging](/arc/frontend/react/queries/paging/) and [observable queries](/arc/frontend/react/queries/observable-queries/) for the appropriate one-shot and streaming hooks. # Model-bound query arguments > Scalar HTTP binding, optional arguments, reserved keys, and explicit paths. ## Bind a scalar argument Arc’s model-bound HTTP readers are not ASP.NET MVC model binding. GET reads named values from the **query string**, not route values. QUERY reads an arguments envelope. Scalar values use the scalar conversion path; JSON arrays keep their element boundaries and Arc converts each element separately. This alternative read-model declaration uses the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) and the configured Arc MongoDB provider. The query searches by an exact account name and a minimum balance: ```csharp using System.Linq; using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; using MongoDB.Driver.Linq; namespace Banking.Accounts; [ReadModel] public record DebitAccount(AccountId Id, AccountName Name, decimal Balance) { [Path("/api/accounts/search")] public static IQueryable Search( IMongoCollection collection, AccountName name, decimal minimumBalance = 0) => collection.AsQueryable().Where(account => account.Name == name && account.Balance >= minimumBalance); } ``` ```http GET /api/accounts/search?name=Savings&minimumBalance=100 ``` Arc converts the incoming name to `AccountName`; the predicate uses that domain value and the requested minimum balance. This is query filtering, **not authorization**: never trust an owner ID supplied by the caller without checking that caller’s access. ## Supported input shapes The built-in conversion path handles scalar values such as strings, numbers, booleans, GUIDs, enums, dates, and supported `ConceptAs` wrappers. A custom `TypeConverter` can extend conversion; test it through each transport you expose. If a supplied scalar or concept value cannot be converted to the declared parameter type, GET and QUERY reject it with HTTP 400 and a validation error naming the argument and its type; the query does not run. This also applies to nullable and defaulted parameters when a value is supplied: invalid input does not fall back to null or the default. It does **not** provide general nested-JSON DTO binding or array/list deserialization. A JSON object in `arguments` does not make an arbitrary `SearchCriteria` parameter bindable. Repeated GET keys bind only to [collections of those scalar types](#collection-arguments), not to collections of objects. Unsupported DTO shapes cannot be constructed by these readers; use a supported scalar or collection argument instead. These limitations concern the supplied HTTP readers. Already-typed arguments passed directly to `IQueryPipeline`, custom readers/converters, and [MVC DTO binding](/arc/backend/csharp/queries/controller-based/query-arguments/) are different paths. FluentValidation’s ability to traverse an object does not prove HTTP can construct that object. ```csharp [ReadModel] public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance) { public static IEnumerable GetAccountsByStatus( AccountStatus status, IMongoCollection collection) { // Implement status filtering logic return status switch { AccountStatus.Active => collection.Find(a => a.Balance > 0).ToList(), AccountStatus.Inactive => collection.Find(a => a.Balance == 0).ToList(), AccountStatus.Suspended => collection.Find(a => a.Balance < 0).ToList(), _ => collection.Find(_ => false).ToList() }; } // A nullable enum works the same way — omit it from the query string to search across every status. public static IEnumerable GetAccountsByOptionalStatus( AccountStatus? status, IMongoCollection collection) { return status.HasValue ? collection.Find(a => a.Balance > 0).ToList() : collection.Find(_ => true).ToList(); } } ``` Arc classifies a method parameter as a caller-supplied query argument — rather than a value resolved from the dependency injection container — when it is a primitive, a concept, an enum (plain or nullable), or a collection of primitives, concepts, or enums. Anything else is injected when the container can supply it, as with `IMongoCollection` or `ILogger`. A type the container does not know, such as an unregistered plain class, is not injected: Arc treats it as a query argument, which the HTTP readers cannot construct. This is why `AccountStatus`/`AccountStatus?` above are read from the query string while `IMongoCollection` is resolved from the container in the same method signature. ### Collection Arguments A collection parameter — `IEnumerable`, an array, `List`, or `HashSet` — is classified the same way as a scalar one: it is a caller-supplied argument whenever its element type is a primitive, a concept, or an enum. This includes nullable elements and scalar types such as `DateOnly`, `TimeOnly`, and `Uri`. For GET, send the argument name repeated once per value (`?ids=1&ids=2&ids=3`); for QUERY, send a JSON array under `arguments` in the request body. Arc binds either form into the collection type your method declares. GET collapses repeated values into a comma-delimited string and splits on every comma, so any element whose text contains a comma (including a `Uri`) cannot round-trip; use QUERY for those values. Collections of `JsonObject` or `JsonArray` elements require QUERY; GET rejects them with HTTP 400. Invalid collection elements and null in non-nullable element types are rejected with HTTP 400 and the same named, malformed-request validation error as invalid scalars. Nested CLR collections such as `int[][]` are not supported; `JsonArray` elements in QUERY are supported. ```csharp [ReadModel] public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance) { public static IEnumerable GetAccountsByIds( IEnumerable ids, IMongoCollection collection) { return collection.Find(a => ids.Contains(a.Id)).ToList(); } public static IEnumerable GetAccountsByOwners( List ownerIds, IMongoCollection collection) { return collection.Find(a => ownerIds.Contains(a.Owner)).ToList(); } public static IEnumerable GetAccountsByStatuses( IEnumerable statuses, IMongoCollection collection) { // Same derived-status logic as GetAccountsByStatus above, matched against any of the requested statuses. return collection.Find(_ => true).ToList().Where(a => statuses.Any(status => status switch { AccountStatus.Active => a.Balance > 0, AccountStatus.Inactive => a.Balance == 0, AccountStatus.Suspended => a.Balance < 0, _ => false })); } } ``` > **The classification rule, stated once:** a parameter is caller-supplied when it is a primitive, a concept, an enum, **or a collection of those** — plain, nullable, or wrapped in `IEnumerable`/an array/`List`/`HashSet` makes no difference. Everything else — a class, an interface, or a collection of any other element type such as `IEnumerable>` — is resolved from the dependency injection container when it is registered there; an unregistered type falls back to being a query argument. ## Missing, empty, and optional values Argument names match case-insensitively. GET and QUERY readers skip empty string values. At invocation, absent optional arguments use the method’s default value. Nullable value types can be omitted; nonnullable value types without defaults are required. Concept nullability/defaults affect requiredness. Plain reference types, including `string`, are implicitly optional in the current performer even without a nullable annotation. **A `string` parameter alone is not a required-input rule.** Use a whole-argument [query validator](/arc/backend/csharp/queries/validation/) to reject missing or empty input. Put required dependencies before optional arguments, as in the example above. ## Reserved keys The GET reader removes these names from ordinary arguments, case-insensitively: | Keys | Purpose | | --- | --- | | `page`, `pageSize` | Arc paging context | | `sortBy`, `sortDirection` | Arc sorting context | | `waitForFirstResult`, `waitForFirstResultTimeout` | Observable HTTP snapshot control | GET also reads the reserved keys case-insensitively (`sortby`, `SORTBY`, `PAGE`, and `PAGESIZE` are equivalent spellings). Do not declare method parameters named `page` or `pageSize` expecting GET to populate them. Use [automatic paging](/arc/backend/csharp/queries/model-bound/paging/), or distinct business argument names if you implement a separate result cap. QUERY places paging/sorting in separate envelope properties; hub subscriptions also have dedicated paging/sorting fields. Keep the distinction explicit across transports. ## URL binding Without `[Path]`, Arc derives a kebab-case path from the namespace and generated-API options, including the configured route prefix, skipped namespace segments, and query-name inclusion. It does not infer `/{id}` from a parameter named `id` or a method named `ById`. Use `Cratis.Arc.Queries.ModelBound.PathAttribute` for an explicit model-bound path. Method-level paths take precedence over a type-level path. Give different queries distinct paths; do not put multiple methods under one identical type-level path and expect separate endpoints. For example, a query configured with `[Path("/api/accounts/by-id")]` takes its GUID-backed `AccountId` as `/api/accounts/by-id?id=11111111-1111-1111-1111-111111111111`, not as an appended route segment. Use [controller route templates](/arc/backend/csharp/queries/controller-based/route-templates/) when you need route-value binding. ## Validation attributes The current model-bound `DataAnnotationValidationFilter` reads validation attributes from the **parameter type**, not from the method parameter’s attributes, and does not recursively inspect DTO properties. `[Required]`, `[MinLength]`, or `[Range]` on a static query parameter therefore do not enforce the advertised rule. This is a current limitation, not a recommendation to omit validation. Use the complete [FluentValidation argument-set example](/arc/backend/csharp/queries/validation/). MVC DataAnnotations follow MVC’s separate rules. # Model-bound query return types > Which return types qualify for discovery and how Arc wraps their results. ## Discovery contract For an `[ReadModel]` type `T`, a query method must return one of these shapes. One outer `Task` is also supported, including around a subject. A supported return shape is necessary, not sufficient: the method must satisfy the [static-method requirements](/arc/backend/csharp/queries/model-bound/static-methods/#method-requirements), including **no parameter of type `T` or nullable `T?`**. Static self-reduction methods therefore remain helpers, not endpoints. | Declared return type | Behavior | | --- | --- | | `T` or nullable `T?` for a reference model | Single model | | `IEnumerable`, `List`, `T[]`, or another collection assignable to `IEnumerable` | Collection of the declaring model | | `IQueryable` | Collection with renderer-based paging/sorting | | `ISubject` | Observable single model | | `ISubject>` or subject of a collection of `T` | Observable collection | | `IAsyncEnumerable` | Asynchronous stream of the declaring model | `Task` without a value, `ValueTask`, arbitrary `IObservable`, unrelated scalar values, unrelated models, and custom result envelopes are not in this discovery contract. A helper returning `int` on `DebitAccount` may compile, but it does not become a count endpoint. Runtime streaming detection and generated-proxy support are separate from C# compilation; test the actual endpoint for any less common shape. ## Collections and absence With MongoDB, materialize a fluent query using `ToList()` or `ToListAsync()` before returning a collection. `collection.Find(...)` itself is an `IFindFluent`, not `IEnumerable`. For [automatic paging](/arc/backend/csharp/queries/model-bound/paging/), return `collection.AsQueryable()` instead of materializing early. A null model-bound result is a successful query with null data, not an automatic 404. An empty list is a successful empty collection. An exception is a failed result; do not convert storage failures into either successful absence shape. [Controller null handling](/arc/backend/csharp/queries/controller-based/return-types/#nullable-return-types) differs. ## Custom return types Put the query on the type it returns. This example reuses the [shared `AccountId` concept](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) and computes a summary from ordinary MongoDB documents; it is not a Chronicle projection. ```csharp using System.Linq; using System.Threading.Tasks; using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; namespace Banking.Accounts; public record AccountDocument(AccountId Id, decimal Balance); [ReadModel] public record AccountSummary(int Count, decimal TotalBalance) { [Path("/api/accounts/summary")] public static async Task Summary(IMongoCollection collection) { var accounts = await collection.Find(_ => true).ToListAsync(); return new AccountSummary(accounts.Count, accounts.Sum(account => account.Balance)); } } ``` This deliberately small example loads all matching documents. Use a database aggregation for large collections, still returning `AccountSummary` from `AccountSummary`. MongoDB `.Project(...)` or LINQ `.Select(...)` are valid ways to shape database results; the result method must still satisfy same-read-model discovery. ## Paged results Return `IQueryable` and let Arc produce the metadata. Do not put a method returning `PagedResult` or `QueryResult` on `T` and expect it to be discovered. If your API genuinely needs a custom envelope, model that envelope as its own read model with its own method or use a controller. Arc’s backend [QueryResult](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata) is nongeneric. It is an infrastructure envelope produced around the method’s data, not the model-bound return type you normally declare. ## Observable return types Returning a subject makes streaming possible; it does not choose a transport. Clients request a snapshot, SSE, or WebSocket. A task around a subject is awaited before streaming begins. Plain subjects do not automatically gain database watching, paging, or teardown of unrelated subscriptions. Continue with [observable queries](/arc/backend/csharp/queries/model-bound/observable-queries/) for complete producer examples and lifetime rules. # Static query methods > Declaration, async execution, and discovery rules for model-bound queries. ## Method requirements Declare queries as non-generic **public static** methods on a `[ReadModel]` type. The read-model type itself may be internal. Give each query a descriptive name and return the declaring model or a [supported wrapper around it](/arc/backend/csharp/queries/model-bound/return-types/). Non-public static helpers, local functions, compiler-generated methods, and property accessors are not query endpoints. Ordinary helper methods returning another type are not query endpoints. Avoid overloaded query names: the method name is part of the query’s identity. A method with a parameter of its own read-model type is **never a query**. The parameter can be nullable or non-nullable, have any name, and appear in any position. This is an exact type check: a collection of the model, a different read-model type, or a service parameter does not trigger the exclusion. Adding `[Query]` to a self-parameter method is an [ARC0026](/arc/backend/csharp/code-analysis/arc0026/) error; remove the marker, not the current-state parameter. Dependencies are resolved by type, not by parameter position. C# still requires optional parameters to follow required parameters, including required service parameters. **Why public only?** A non-public helper must never become reachable over HTTP merely because it returns the read model. Earlier versions supported internal query methods; those methods are no longer exposed or given TypeScript query proxies. [ARC0029](/arc/backend/csharp/code-analysis/arc0029/) warns on non-public query-shaped static methods and non-public statics marked `[Query]`. Make a method public only when you intend to expose it, and review its authorization before doing so. `[Query]` cannot override visibility. ## Choose endpoints explicitly when a model has helpers `[ReadModel]` uses conventional discovery by default. Adding `[Query]` to one method does **not** hide its siblings or change that mode. Keep convention for a model whose eligible static methods are all queries. Self-parameter folds are excluded automatically. Set `RequireQueryAttribute = true` when the same type also has factories or other query-shaped helpers **without** a self parameter: ```csharp using System.Threading.Tasks; using Cratis.Arc.Queries.ModelBound; namespace Banking.Accounts; [ReadModel(RequireQueryAttribute = true)] public record AccountSummary(string Name) { public static AccountSummary Create(string name) => new(name); public static Task Prepare(object service) => Task.CompletedTask; [Query] public static AccountSummary? ByName(string name) => new(name); } ``` Only `ByName` is an endpoint. Unmarked helpers can be public, internal, private, static, or instance methods, with any return type or service parameters. They receive no query-signature, generic-query, or concept-query-parameter diagnostics. Non-public query-shaped statics still receive the migration warning [ARC0029](/arc/backend/csharp/code-analysis/arc0029/). Direct C# calls are unchanged. A type with no marked methods has no queries. A marked query must still be an ordinary public static nongeneric method on a `[ReadModel]`, with value parameters and a supported return shape. Nullable model results, collections, `Task` wrapping those shapes, `IAsyncEnumerable`, and `ISubject` of the model or its collection are supported in explicit mode. Plain `Task`, `ValueTask`, non-public or instance methods, and by-reference parameters or results are not valid marked queries; [ARC0026](/arc/backend/csharp/code-analysis/arc0026/) reports them. The Arc marker is `Cratis.Arc.Queries.ModelBound.QueryAttribute`, not Chronicle’s gRPC marker. The nearest `[ReadModel]` declaration supplies the setting. A direct `[ReadModel]` on a derived type resets it to false. All partial declarations share the setting; a nested type does not inherit its enclosing type’s setting. Arc does not start enumerating inherited static methods. In explicit mode a marked `ById(string)` can coexist with an unmarked `ById(int)` helper. Two selected methods with the same name are an error, because query identity contains the name rather than an overload signature. Conventional overload selection is unchanged. ## Preview a migration 1. List the currently exposed query names and routes, including every partial declaration. 2. Set `RequireQueryAttribute = true` and mark every endpoint you intend to retain with `[Query]`. 3. Compare runtime discovery, generated query metadata, TypeScript proxies, and Screenplay extraction with the intended endpoint list. Preserve each endpoint’s `[Path]`, HTTP method, argument defaults, and authorization attributes. 4. Exercise retained queries through the pipeline or `QueryScenario`, including observable queries, and confirm helpers are absent. `[Path]`, `[QueryHttpMethod]`, and authorization on an unmarked helper do not make it an endpoint. No method name is globally reserved. A `Reduce` or `Create` method **with a self parameter** is excluded by its signature, not its name. Helpers without a self parameter remain subject to conventional discovery and diagnostics. The optional Chronicle integration warning [ARCCHR0019](/arc/backend/csharp/chronicle/code-analysis/arcchr0019/) flags some creator shapes without changing discovery. Its whole-type quick fix requires an endpoint preview; it cannot prove which helpers you intended as queries. For a record that combines static reductions with ordinary queries, see [self-reducing read models](/arc/backend/csharp/chronicle/read-models/#self-reducing-read-models). ## Async methods This alternative declaration uses the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names) and an Arc host with the MongoDB provider configured: ```csharp using System.Collections.Generic; using System.Threading.Tasks; using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; namespace Banking.Accounts; [ReadModel] public record DebitAccount(AccountId Id, AccountName Name, decimal Balance) { [Path("/api/accounts/search")] public static async Task> Search( IMongoCollection collection, AccountName name) => await collection.Find(account => account.Name == name) .SortBy(account => account.Name) .Limit(100) .ToListAsync(); [Path("/api/accounts/by-id")] public static async Task ById( AccountId id, IMongoCollection collection) => await collection.Find(account => account.Id == id).FirstOrDefaultAsync(); } ``` `Search` composes the MongoDB fluent query **before** materializing it. `FindAsync()` returns a cursor, not a fluent query; do not call `Limit()` or `Skip()` on that cursor. The fixed 100-row cap here is not Arc paging and supplies no total-count metadata. Arc awaits the task. `ById` can return null successfully in the model-bound pipeline; that is not an automatic HTTP 404. Do not catch database failures and return null or an empty list: that hides a failure as a successful read. ## Observable methods `Task>` and `Task>>` are accepted after the `Task` is unwrapped. A task prepares the stream; the subscription then delivers its values. Returning a subject does not itself open a WebSocket: the client/request selects the transport. Use the [observable-query guide](/arc/backend/csharp/queries/model-bound/observable-queries/) for producer lifetime and transport behavior, then [query arguments](/arc/backend/csharp/queries/model-bound/query-arguments/) for binding rules. # Observable query hub protocol > Multiplexed WebSocket and SSE subscription lifecycle, message strings, revisions, and transfer modes. ## Endpoints The demultiplexer carries multiple named observable queries over one connection. **Both WebSocket and SSE are multiplexed.** Arc maps four fixed routes: | Method/transport | Route | Purpose | | --- | --- | --- | | WebSocket upgrade | `/.cratis/queries/ws` | Bidirectional subscription and result messages | | GET, SSE | `/.cratis/queries/sse` | Long-lived output stream | | POST, JSON | `/.cratis/queries/sse/subscribe` | Add/replace a subscription on an SSE connection | | POST, JSON | `/.cratis/queries/sse/unsubscribe` | End a subscription on an SSE connection | A direct per-query SSE GET is a different protocol: it streams plain `QueryResult` frames for that route, not hub envelopes. See [cURL streaming](/arc/backend/csharp/queries/using-observable-queries-with-curl/#stream-updates-over-sse). ## Message types `ObservableQueryHubMessage.Type` has its own `JsonStringEnumConverter`. The server emits the following **PascalCase string values**, not the camelCase field names or numeric enum constants. Numeric values are listed only to identify the enum; use strings in new protocol clients. | `type` | Enum value | Purpose/payload | | --- | --- | --- | | `Subscribe` | 0 | Client sends a subscription request in `payload` | | `Unsubscribe` | 1 | Client ends `queryId` | | `QueryResult` | 2 | Server sends a `QueryResult` in `payload` | | `Unauthorized` | 3 | Server denies the subscription | | `Error` | 4 | Server sends an error string in `payload` | | `Ping` | 5 | Keep-alive; `timestamp` is Unix milliseconds | | `Pong` | 6 | Echo of a ping timestamp | | `Connected` | 7 | Connection ID in `payload`, plus capability/keep-alive metadata | Envelope properties are `type`, `queryId`, `revision`, `payload`, `timestamp`, `keepAliveIntervalMs`, and `supportsSubscriptionRevisions`. Not every field is meaningful on every message; nullable/default fields may be omitted according to serializer configuration. ## WebSocket transport After upgrade, the server sends `Connected` before reading subscriptions. Illustrative frame (null fields omitted for readability): ```json { "type": "Connected", "payload": "ws-1", "supportsSubscriptionRevisions": true, "keepAliveIntervalMs": 30000 } ``` For the `Banking.Accounts.DebitAccount.ObserveAccounts` method in the [model-bound example](/arc/backend/csharp/queries/model-bound/observable-queries/), send: ```json { "type": "Subscribe", "queryId": "accounts-list", "revision": 1, "payload": { "queryName": "Banking.Accounts.DebitAccount.ObserveAccounts", "arguments": { "minimumBalance": "0" }, "page": 0, "pageSize": 25, "sortBy": "name", "sortDirection": "asc", "transferMode": "delta" } } ``` `queryName` is the fully qualified **performer name**, not its HTTP path. `queryId` is chosen by the client and is unique per active subscription within that connection. Arguments are a string-value dictionary; nested JSON/array binding is not added by this protocol. Paging, sorting, and transfer mode are optional request properties. Updates carry `type: "QueryResult"`, the matching `queryId` and revision, and the [result envelope](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata) in `payload`. Subscribe again with a different `queryId` to observe another query on the same connection. To end the first subscription: ```json { "type": "Unsubscribe", "queryId": "accounts-list", "revision": 1 } ``` A client `Ping` receives a `Pong` with its timestamp. Connection shutdown disposes the connection’s subscriptions; it is not a durable replay checkpoint. ## SSE transport 1. Open `GET /.cratis/queries/sse` and keep it open. 2. Read the first `Connected` frame and retain its GUID connection ID. 3. POST a subscription body to `/.cratis/queries/sse/subscribe` using that ID. 4. Read updates on the **original GET**, correlated by `queryId` and revision. 5. POST unsubscribe when done; close the GET to end all its subscriptions. ``` HubClientHubClientGET /.cratis/queries/sseConnected (connectionId, capabilities)POST subscribe (connectionId, queryId A, request)POST subscribe (connectionId, queryId B, request)SSE QueryResult for ASSE QueryResult for BPOST unsubscribe (connectionId, queryId A)Close GET (ends B too) ``` Illustrative subscribe body; replace the connection ID with the one just received: ```json { "connectionId": "11111111-1111-1111-1111-111111111111", "queryId": "accounts-list", "revision": 1, "request": { "queryName": "Banking.Accounts.DebitAccount.ObserveAccounts", "arguments": { "minimumBalance": "0" }, "transferMode": "full" } } ``` The unsubscribe body is: ```json { "connectionId": "11111111-1111-1111-1111-111111111111", "queryId": "accounts-list", "revision": 1 } ``` Send both POSTs as `application/json`. The validated control requests return 400 for missing required values/invalid revisions, 404 for unknown connections, and normally 200 after processing. Subscribe returns 401 when authorization denies the query; denial is also delivered as an `Unauthorized` frame. A 200 control response is not proof that a first data result arrived—observe the stream’s results/errors. `?query=...` on the SSE GET does **not** subscribe. On reconnect, wait for the new `Connected` ID and re-create desired subscriptions. Connection state is server-process-local; route the GET and its control POSTs to the same instance in a multi-instance deployment. ## Revisions and compatibility When `Connected.supportsSubscriptionRevisions` is true, use positive monotonically increasing revisions for each `queryId`. A higher subscribe revision replaces older work; duplicate or stale subscribes are ignored. Unsubscribe uses the exact revision being canceled, and can arrive before a delayed subscribe to tombstone it. An older unsubscribe cannot tear down a newer subscription. Discard stale result/error/denial frames on the client. Legacy clients may omit revisions. Once a query ID has become revision-aware, revisionless operations cannot replace/cancel that state. Older servers may omit `Connected` on WebSocket or omit the capability field; clients requiring compatibility must support the legacy behavior rather than indefinitely waiting for an advertisement those servers never send. Tombstones are bounded: the current implementation retains them for two minutes and at most 1,024 inactive entries per connection. This is an ordering window, not indefinite deduplication or durable resumption. ## Transfer modes For subject-backed collections, omitted `transferMode` uses legacy snapshot-plus-delta behavior; `full` sends snapshots only; `delta` sends an initial snapshot then changes without full data. See [change streams](/arc/backend/csharp/queries/change-stream/) for the first/subsequent emission matrix. Arbitrary subjects do not automatically implement paging just because subscription metadata requests it. Paging metadata belongs to collection results. Single-result subjects, including strings and value types, report `PagingInfo.NotPaged` rather than copying `TotalItems` from an inner observation. Collection delta emissions keep their paging metadata even when they omit the full data snapshot. This only controls the result envelope; it does not normalize incoming paging arguments or change how an application reads its sources. For MongoDB queries that compose observations, see [auxiliary sources](/arc/backend/csharp/mongodb/observing-collections/#compose-a-primary-observation-with-auxiliary-sources). ## Authorization and exposure The hub transport endpoints themselves allow anonymous access so one connection can carry both public and protected queries. Before running each query, the hub applies the host’s default authorization: ASP.NET Core’s `FallbackPolicy`, or the Arc Core host’s requirement for an authenticated caller when authentication handlers are registered. A query marked with Arc’s `[AllowAnonymous]`, or whose effective declaration consists entirely of opted-in guest policies, bypasses that host fallback just as its direct endpoint does. The query pipeline still evaluates its own authorization filters and guest policies. A fallback denial or failure sends `Unauthorized` without running the query; SSE subscribe also returns 401. The SSE GET and unsubscribe routes do not execute queries. The same [model-bound authorization](/arc/backend/csharp/queries/model-bound/authorization/) rules apply to a subscription as to a one-shot request. WebSocket credentials come from the live upgrade request; SSE credentials come from the live subscribe POST. Consistently send the application’s normal credentials on connection/control requests. The SSE control handlers verify that the POST caller is the caller that opened the GET stream. The identity is captured once when the stream opens - while that request is still in flight, so the retained context is never read again - and each subscribe and unsubscribe is compared against it on the identity identifier claim and authentication state. A mismatch returns 404, exactly as an unknown connection ID does, so the endpoints never confirm that another caller’s connection exists. The display name is not part of that comparison, because it is mutable and a stream held open across a token refresh would otherwise stop accepting its own caller’s control requests. The tenant is not part of it either: a control POST is authorized with the tenant resolved from that POST, not the one the connection was opened with. A connection opened by an unauthenticated caller carries no identity to bind to, so ownership is not distinguishable between anonymous callers. Treat the SSE connection ID as sensitive connection-control data rather than as an authorization policy, and do not publish or unnecessarily log connection IDs. Query authorization still checks the subscribe POST’s right to read the query, which is a separate question from ownership of the destination stream. The anonymous [health feed](/arc/backend/csharp/queries/query-health/) includes connection/subscriber metadata and needs explicit protection too. The initial verdict does not automatically revoke a long-lived stream. Use [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/) to check current permission/session state during delivery. Guards also run before the hub sends a non-streaming query’s one-shot result, with `IsFirstEmission=true` and `IsSnapshot=true`. A denied snapshot sends `Unauthorized`; a suppressed snapshot sends no result, error, or denial frame and ends without a continuing subscription. An SSE subscribe POST still returns 200 for suppression. ## Keep-alive `ArcOptions.Query.KeepAliveInterval` defaults to 30 seconds. Idle WebSocket/SSE connections receive a `Ping`; ongoing data suppresses unnecessary keep-alives. Zero or negative disables keep-alive, advertised as `keepAliveIntervalMs: 0` on `Connected`. Clients should derive their idle threshold from that advertisement rather than hard-coding 30 seconds. See [frontend multiplexing](/arc/frontend/react/queries/observable-query-multiplexing/) for the supplied client rather than implementing the lifecycle from scratch. # Observable query emission guards > Re-check access before observable query emissions, hub snapshots, and observable HTTP snapshots. Authorization for an observable query runs when the subscription is established. That verdict decides whether the caller may **obtain** the live stream — it says nothing about the minutes or hours the stream then stays open. That is fine for most applications. Subscriptions end when the user navigates away, the tab closes, or the server shuts down. But a subscription has no other reason to end: nothing terminates it when a token expires, a session is signed out, or a role is revoked. Keep-alive pings deliberately stop proxies from culling an idle connection, and the client reconnects and re-subscribes on its own. An **emission guard** closes that gap. Implement `IGuardObservableQueryEmission` and Arc consults it for every emission an observable query is about to write, on every transport — the multiplexed hub, direct WebSocket/SSE connections, and plain-HTTP observable snapshots alike. Guards also check non-streaming query results delivered through the hub. Read-model interception runs before the guard. ## Writing a guard Illustrative application integration fragment: `ISessions` is your application’s real session store contract, not an Arc API. Supply and register an implementation that checks authoritative session state; do not replace it with an always-true stub. This guard deliberately requires a session for every stream or hub snapshot it evaluates. ```csharp using System.Threading.Tasks; using Cratis.Arc.Queries; namespace Application.Security; public interface ISessions { Task IsActive(string sessionId); } public class SessionMustStillBeActive(ISessions sessions) : IGuardObservableQueryEmission { public async Task Guard(ObservableQueryEmissionContext context) { var sessionId = context.Principal?.FindFirst("sid")?.Value; if (sessionId is null) { return ObservableQueryEmissionVerdict.DenyAndTerminate; } return await sessions.IsActive(sessionId) ? ObservableQueryEmissionVerdict.Allow : ObservableQueryEmissionVerdict.DenyAndTerminate; } } ``` That is the whole opt-in. Guards are discovered by convention — no registration, no configuration. An application with no guard builds no emission context and dispatches nothing; emissions take their existing path. A filter that supplies a scope still incurs the subscription-time snapshot, even if no guard is installed. ## Compare the scope captured at subscription time When a query filter chooses which organization’s data to stream, its decision may change while the connection stays open. Put a serializable value in `QueryContext.SubscriptionScope` during filtering in Arc’s query pipeline. Arc captures it **after the filters succeed and before the query executes**, then passes a fresh copy in `ObservableQueryEmissionContext.SubscriptionScope` on each emission. Values assigned during query execution are too late and do not change the captured scope. The guard can compare that original scope to current membership without rerunning the filter or changing the query arguments. Controller-based observable queries do not run `IQueryFilters` through `QueryActionFilter.EstablishQueryContext`. Their emission guards always receive a `null` subscription scope; use a query handled by Arc’s query pipeline if you need a filter-supplied scope. This application integration fragment uses `IMemberships` as an application-owned contract. Supply an implementation backed by authoritative membership state; `CurrentOrganizationId` must return the current organization, not a value cached at login. Replace the query name with your observable query’s fully qualified name. The query itself must use the same filter-derived organization to select its data — setting the scope alone does not filter results. ```csharp using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Authorization; using Cratis.Arc.Queries; namespace Application.Security; public interface IMemberships { Task CurrentOrganizationId(ClaimsPrincipal principal, CancellationToken cancellationToken); } public class CaptureOrganizationScope(ICurrentPrincipalAccessor principalAccessor, IMemberships memberships) : IQueryFilter { public async Task OnPerform(QueryContext context) { if (context.Name.Value != "Application.Queries.ForCurrentOrganization") { return QueryResult.Success(context.CorrelationId); } var principal = principalAccessor.Current; if (principal is null) { return QueryResult.Unauthorized(context.CorrelationId); } var organizationId = await memberships.CurrentOrganizationId(principal, context.CancellationToken); if (organizationId is null) { return QueryResult.Unauthorized(context.CorrelationId); } context.SubscriptionScope = organizationId; return QueryResult.Success(context.CorrelationId); } } public class OrganizationMustStillMatch(IMemberships memberships) : IGuardObservableQueryEmission { public async Task Guard(ObservableQueryEmissionContext context) { if (context.QueryName.Value != "Application.Queries.ForCurrentOrganization") { return ObservableQueryEmissionVerdict.Allow; } if (context.Principal is null || context.SubscriptionScope is not string originalOrganization) { return ObservableQueryEmissionVerdict.DenyAndTerminate; } var currentOrganization = await memberships.CurrentOrganizationId(context.Principal, context.CancellationToken); return currentOrganization == originalOrganization ? ObservableQueryEmissionVerdict.Allow : ObservableQueryEmissionVerdict.DenyAndTerminate; } } ``` For example, a subscription established while membership is `former` captures `former`. If membership changes to `new` with **unchanged query arguments**, the next emission still carries `former`, and the guard returns `DenyAndTerminate`. A subscription created after the change captures `new` independently. With no scope supplied, the emission context’s scope is `null`; a supplied scope that cannot be serialized and restored fails the query explicitly as `InvalidSubscriptionScope` rather than silently becoming `null`. Keep the scope small: prefer primitives or records of primitive values. Only data serialized by Arc’s `JsonSerializerOptions` survives the snapshot. Private fields, `[JsonIgnore]` members, and derived members of non-polymorphic base-typed properties are dropped even when the remaining value can round-trip. Do not put live services or mutable authorization caches in it. ## The three verdicts | Verdict | What happens | | --- | --- | | `Allow` | The emission is written unchanged. | | `Suppress` | This one emission is withheld. The subscription stays live and the next emission is evaluated again. | | `DenyAndTerminate` | Nothing is written, the client is told it is unauthorized, and this subscription is torn down. | For an HTTP observable snapshot, `Allow` returns intercepted data with **200 OK** and `DenyAndTerminate` returns **403 Forbidden** without data. `Suppress` returns **202 Accepted** without data if you did not request waiting. With `waitForFirstResult=true`, Arc keeps evaluating emissions until one is allowed, the query is denied, or the original deadline expires with **408 Request Timeout**. Suppression does not restart the deadline; every candidate for this snapshot carries `IsFirstEmission=true`. For a non-streaming query delivered through the hub, the guard receives `IsSnapshot=true` and `IsFirstEmission=true`. `Allow` sends the one-shot result; `DenyAndTerminate` sends `Unauthorized` without data (and SSE subscribe returns 401). `Suppress` sends no result, error, or denial frame and ends the operation: there is no stream to evaluate again. The SSE subscribe POST returns 200 for suppression, not an indication that data arrived. `Suppress` does **not** move the delta baseline. The client never saw the withheld emission, so the next delivered `ChangeSet` is still computed against the last state it actually received — nothing goes missing. `DenyAndTerminate` ends only the subscription it was given. Every sibling subscription on the same multiplexed connection keeps streaming. On the hub the client receives an `Unauthorized` message for that query id and the subscription is deleted; on a direct connection it receives a final `QueryResult` with `IsAuthorized` false and the stream closes. In both cases the Arc client latches the denial and stops reconnecting — a reconnect would only be denied again, and on a direct SSE connection the browser would otherwise re-establish the stream every few seconds and re-run the whole query for each attempt. ## What the guard is told `ObservableQueryEmissionContext` carries the fully qualified query name, the coerced query arguments, the captured `SubscriptionScope` (or `null`), the caller’s `ClaimsPrincipal`, the correlation id, whether this is the first emission on the subscription, `IsSnapshot` (true only for non-streaming hub results; false for stream emissions and HTTP observable snapshots), the subscription’s cancellation token, and the `IServiceProvider` to resolve from. Each guard receives its own copy of the scope and arguments; mutation by one guard cannot change another guard’s input or a later emission. The principal is handed over **explicitly**. Emissions arrive on the producing stream’s own thread, where the request’s `AsyncLocal` context does not flow, so a guard that reached for an ambient accessor would see the wrong identity — or none at all. ## Several guards Every guard is asked, and the most restrictive verdict wins: `DenyAndTerminate` over `Suppress` over `Allow`. The first `DenyAndTerminate` short-circuits the rest. ## Failing closed A guard that throws is treated as `DenyAndTerminate`. The failure is logged at `Error` and never rethrown. This is deliberate. A guard that failed open would leave an application believing its stream is protected while it keeps flowing — which is worse than having no guard at all, because nobody goes looking. Know the blast radius before you write one. The aggregator is a process-wide singleton over every discovered guard type, so a single guard that cannot be constructed at all — an open generic, or one whose constructor takes something the subscription scope cannot resolve — denies **every** observable query in the application, on every transport, for as long as the process runs. The only symptom is the `Error` log; clients simply see their subscriptions end as unauthorized. Cover a new guard with a spec that exercises its real constructor. One exception is carved out: if the subscription’s own `CancellationToken` is canceled while the guard is running, the resulting `OperationCanceledException` is the client going away, not a guard failing. The emission is still withheld and the subscription still ends, but nothing is logged as a failure — otherwise every closed tab would produce an authorization error. ## Cost The guard runs on **every** emission of **every** subscription. Keep it fast: prefer cached state, a local revocation list, or a short-TTL lookup over a network round trip per emission. **The guard instance itself is constructed per emission.** Guards are resolved with `GetServiceOrCreateInstance`, and Arc’s `IFoo → Foo` convention does not match a guard named for what it decides (`SessionMustStillBeActive` implements `IGuardObservableQueryEmission`, not `ISessionMustStillBeActive`), so unless the application registers the type explicitly it is newly constructed — constructor and all — for every emission, and never disposed. Keep the constructor trivial, and put any cache in an injected singleton rather than in a field on the guard, where it would be thrown away and rebuilt each time. Resolve collaborators through the constructor. They come from the subscription’s own scope and are disposed with it, so a guard can safely hold a scoped session store or tenant-aware service. ## Where it does not help Guards protect emitted values, including observable HTTP snapshots and non-streaming hub results; they do not replace subscription-time authorization or safe data selection. Direct HTTP non-observable queries do not invoke emission guards: those requests authenticate again on each call. Apply query authorization to every query and keep [read-model interception’s opt-out boundaries](/arc/backend/csharp/queries/read-model-interception/) in mind. On a WebSocket the principal is frozen at the handshake — the protocol offers no way to present fresh credentials on an established connection. The identity a guard receives on a WebSocket subscription is the one captured when the socket was upgraded, and it does not change for the life of that connection. That does not make the guard useless there; it makes it a lookup rather than a re-read. Take the identity from the context and ask your own source of truth — a session store, a token introspection endpoint, a revocation list — whether that identity is still good. ## See also * [Observable Query Hub](/arc/backend/csharp/queries/observable-query-demultiplexer/) — the multiplexed transport and its subscription lifecycle. * [Read Model Interception](/arc/backend/csharp/queries/read-model-interception/) — transforming each emitted read model before it is written. * [Authorization](/arc/backend/csharp/core/authorization/) — the subscription-time verdict this builds on. # Query health endpoint > Inspect tracked hub subscriptions and configure authentication and roles for the diagnostic feed. Arc provides a live health feed at `/.cratis/queries/health`. The feed is itself a model-bound observable query, publishing the subscription state recorded by the hub health tracker. The feed exposes cross-user connection IDs, query names, remote IP addresses, user agents, and user identities when available. It allows anonymous access in Development and requires an authenticated caller everywhere else, including Production and Staging. These defaults apply to the direct endpoint and named subscriptions over both WebSocket and SSE hubs. This is diagnostic telemetry, not a public liveness endpoint. ## Why this endpoint exists When something goes wrong with real-time data — a component freezes, a query never receives its first result, or a user reports stale data — the question is always: *is the issue in the frontend cache, the transport connection, or the backend subscription?* The health endpoint gives you a backend-authoritative answer without attaching a debugger or tailing logs. The query identifiers in the health snapshot use the same fully-qualified format (`{TypeFullName}.{MethodName}`) that the proxy generator writes into the generated TypeScript proxies. You can match a frontend cache entry to a backend subscriber by name alone. ## Subscribing to the feed For a direct stream from a trusted diagnostic client, use the configured host origin and normal credentials: ```bash curl --no-buffer --max-time 30 --header 'Accept: text/event-stream' \ 'https://localhost:5001/.cratis/queries/health' ``` For hub subscriptions, use the fully qualified name `Cratis.Arc.Queries.QueryHealth.ObserveHealth` and the [hub subscription lifecycle](/arc/backend/csharp/queries/observable-query-demultiplexer/). Do not infer a generated TypeScript import path from the HTTP route; use the proxy actually generated into your application’s configured output directory. ## Configure access Configure `ArcOptions.QueryHealth` in code or bind its settings from `Cratis:Arc:QueryHealth`. For example, this `appsettings.json` excerpt requires the `QueryDiagnostics` role, including in Development: ```json { "Cratis": { "Arc": { "QueryHealth": { "RequireAuthentication": true, "Roles": "QueryDiagnostics" } } } } ``` | Option | Behavior | | --- | --- | | `RequireAuthentication` | When unset, anonymous in Development and authenticated elsewhere. Explicit `true` requires authentication in every environment. Explicit `false` allows anonymous access when no roles are configured. | | `Roles` | Optional comma-separated roles; any one grants access. Entries are trimmed and empty entries ignored. Setting roles requires authentication in every environment, even with `RequireAuthentication=false`. | Configure real authentication and role mapping on the host. Outside Development, a host without authentication cannot admit callers to the feed. To deliberately restore anonymous access, set `Cratis:Arc:QueryHealth:RequireAuthentication=false` and leave `Roles` unset. This exposes cross-user diagnostic data, so use it only on a trusted host or network. These settings apply only to `QueryHealth.ObserveHealth`; they do not relax host fallback authorization for other queries. They govern admission, not revocation of an already authorized live stream; use [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/) for ongoing revocation. ## Response shape A single snapshot has two views of recorded subscription state: **connection-centric** and **query-centric**. These fields describe the model inside `QueryResult.data`, not the outer Arc envelope. Date values are ISO strings on the JSON wire; a generated client may deserialize them to `Date`. ### Top-level fields | Field | Type | Description | | --- | --- | --- | | `connections` | `QueryConnectionHealth[]` | One entry per tracked hub connection (WebSocket or SSE); empty entries may remain briefly after unsubscribe. | | `totalConnections` | `number` | Total count of tracked connections. | | `totalSubscriptions` | `number` | Total count of recorded subscriptions across tracked connections. | | `querySubscriptions` | `QuerySubscriptionAggregate[]` | Query-centric view — one entry per distinct query name. | ### QueryConnectionHealth | Field | Type | Description | | --- | --- | --- | | `connectionId` | `string` | Unique connection identifier. WebSocket connections use an incrementing `ws-N` label; SSE connections use a GUID. | | `protocol` | `string` | `WebSocket` or `SSE`. | | `establishedAt` | `Date` | When the tracker first registered a subscription for the connection. | | `subscriptions` | `QuerySubscriptionMetadata[]` | All subscriptions routed through this connection. | ### QuerySubscriptionMetadata | Field | Type | Description | | --- | --- | --- | | `subscriptionId` | `string` | The client-generated query ID (`queryId` in the WebSocket protocol). | | `queryIdentifier` | `string` | Fully-qualified query name — matches `queryName` on the generated TypeScript proxy. | | `readModelType` | `string` | Fully-qualified name of the read model type only (without the method). | | `connectedAt` | `Date` | When this subscription was first established. | | `clientInfo` | `QuerySubscriptionClientInfo` | Remote IP, user agent, user identity, and protocol. | | `lastPingSentAt` | `Date?` | Last time a keep-alive ping was sent to this subscriber. | | `lastPongReceivedAt` | `Date?` | Last time a pong was received back. | | `lastDataServedAt` | `Date?` | Last time a data frame was sent to this subscriber. | ### QuerySubscriptionAggregate The query-centric view groups all physical subscribers for a single query name into one entry. The `queryName` field uses the same identifier format as the frontend proxy’s `queryName` property, making it straightforward to correlate the two sides. | Field | Type | Description | | --- | --- | --- | | `queryName` | `string` | Fully-qualified query name — identical to `queryName` on the generated TypeScript proxy. | | `totalSubscriptions` | `number` | Number of physical subscribers for this query. | | `subscribers` | `QuerySubscriber[]` | One entry per physical connection/subscription pair. | ### QuerySubscriber | Field | Type | Description | | --- | --- | --- | | `connectionId` | `string` | The parent connection this subscriber belongs to. | | `protocol` | `string` | `WebSocket` or `SSE`. | | `subscriptionId` | `string` | The client-generated subscription identifier. | | `connectedAt` | `Date` | When this subscription was established. | | `clientInfo` | `QuerySubscriptionClientInfo` | Remote IP, user agent, user identity, and protocol. | | `lastPingSentAt` | `Date?` | Last ping sent. | | `lastPongReceivedAt` | `Date?` | Last pong received. | | `lastDataServedAt` | `Date?` | Last data frame sent. | ### QuerySubscriptionClientInfo | Field | Type | Description | | --- | --- | --- | | `remoteIpAddress` | `string?` | Client IP address. | | `userAgent` | `string?` | Browser or client user-agent string. | | `userId` | `string?` | Authenticated user identity, if any. | | `protocol` | `string` | `WebSocket` or `SSE`. | ## A snapshot in JSON Illustrative `QueryResult.data` value, not a captured response or the full envelope: ```json { "connections": [ { "connectionId": "ws-1", "protocol": "WebSocket", "establishedAt": "2026-06-10T14:03:00Z", "subscriptions": [ { "subscriptionId": "all-authors-main", "queryIdentifier": "MyApp.Authors.Listing.AllAuthors", "readModelType": "MyApp.Authors.Listing", "connectedAt": "2026-06-10T14:03:01Z", "lastPingSentAt": "2026-06-10T14:04:00Z", "lastPongReceivedAt": "2026-06-10T14:04:00Z", "lastDataServedAt": "2026-06-10T14:03:01Z", "clientInfo": { "protocol": "WebSocket", "remoteIpAddress": "127.0.0.1", "userAgent": "Mozilla/5.0 ...", "userId": "alice@example.com" } } ] } ], "totalConnections": 1, "totalSubscriptions": 1, "querySubscriptions": [ { "queryName": "MyApp.Authors.Listing.AllAuthors", "totalSubscriptions": 1, "subscribers": [ { "connectionId": "ws-1", "protocol": "WebSocket", "subscriptionId": "all-authors-main", "connectedAt": "2026-06-10T14:03:01Z", "lastPingSentAt": "2026-06-10T14:04:00Z", "lastPongReceivedAt": "2026-06-10T14:04:00Z", "lastDataServedAt": "2026-06-10T14:03:01Z", "clientInfo": { "protocol": "WebSocket", "remoteIpAddress": "127.0.0.1", "userAgent": "Mozilla/5.0 ...", "userId": "alice@example.com" } } ] } ] } ``` ## Transport modes The hub records both multiplexed WebSocket and multiplexed SSE subscriptions. WebSocket connections have `ws-N` identifiers; SSE hub connections have GUID identifiers and may carry multiple subscriptions each. Do not confuse this with direct per-query SSE. The current registration call sites are in the demultiplexer. Do not treat the feed as a complete inventory of direct per-query WebSocket/SSE connections, or of idle hub connections with no registered subscriptions. `querySubscriptions` groups the subscriptions that were actually recorded; absence from this feed alone does not prove no direct client is watching a query. ## Cross-stack correlation The `queryName` in `QuerySubscriptionAggregate` and the `queryIdentifier` in `QuerySubscriptionMetadata` both use the format `{TypeFullName}.{MethodName}` — the same string the proxy generator writes as the `queryName` field on generated TypeScript proxy classes. Because both sides share the same identifier, you can match a frontend cache entry to its backend subscriptions by name. For example, if the frontend diagnostics report that the cache entry for `MyApp.Authors.Listing.AllAuthors` is not subscribed, you can verify in the backend health feed whether a subscription for that name exists at all, which connection carries it, and when data was last served. See [Observable Query Diagnostics](/arc/frontend/react/queries/observable-query-diagnostics/) for how to access the matching frontend diagnostics. ## See also * [Observable Query Demultiplexer](/arc/backend/csharp/queries/observable-query-demultiplexer/) — How multiplexed and direct-mode connections work. * [Frontend: Observable Query Diagnostics](/arc/frontend/react/queries/observable-query-diagnostics/) — The frontend diagnostics surface and how to correlate it with this endpoint. # Query pipeline and result contract > Model-bound execution stages, MVC differences, extension points, and QueryResult fields. ## Model-bound execution `IQueryPipeline` performs a query by fully qualified name. Generated model-bound endpoints and hub subscriptions use it. Database/provider code remains responsible for producing data; Arc does not turn a database read into a Chronicle projection. ``` Read request arguments Validate requested paging Find performer Coerce arguments and establish context Authorization filters Resolve query dependencies Ordinary filters Invoke and await query method Render data and attach metadata Intercept ordinary data or hand stream to transport ``` A non-success filter verdict stops execution before the query method. With Arc’s built-in (staged) filters, an authorization denial also stops before the query method’s dependencies are resolved from the container. It does not imply no collaborators ran: paging validation, performer lookup, and argument conversion occur before authorization, and a custom filter implementation that is not staged resolves dependencies before its filters run. Custom filters/dependencies are application code, not guaranteed side-effect-free. A null method result can remain a successful model-bound result. For a stream, the pipeline passes the wrapper onward; transport handling decides how to consume and deliver values. ## Controller-specific path MVC GET actions use `QueryActionFilter`, not this model-bound filter chain. The action filter establishes context, consults MVC model state, invokes the action, renders data, applies ordinary interception, and creates an Arc result. It recognizes streaming values through the controller adapter. `[AspNetResult]` opts out. POST actions do not use the GET query action filter. Null data in a default wrapped MVC GET becomes `"Null data returned"`, unlike model-bound null success. See [controller return types](/arc/backend/csharp/queries/controller-based/return-types/). ## Query context and renderers `IQueryContextManager.Current` exposes query identity, correlation, arguments, dependencies, paging, sorting, total count, and `ReceivedAt`. `ReceivedAt` is a `DateTimeOffset` captured when Arc receives the operation: before argument binding and authorization preparation at a model-bound HTTP endpoint, at public pipeline entry for direct calls, and separately for each hub subscribe operation. It is not network arrival time or time before application middleware; MVC action filters capture it after MVC binding. It remains the same through filters, performers, and Arc-owned replacement service scopes. During a decorated transport dispatch, sibling public pipeline calls (including a query followed by a command) share the transport receipt. A pipeline call started from inside another pipeline entry captures a new receipt and restores the outer value afterward. Work that starts a pipeline after the dispatch finishes captures a fresh receipt, even if its execution context was created during dispatch. `ReceivedAt` describes the current operation; do not treat it as persistent per-user state. A validator resolved from the query’s service provider can inject `Cratis.Arc.IOperationContextAccessor`; its nullable `ReceivedAt` is the same value during the operation and null outside it. MVC-bound validators running during model binding, before the query action filter starts the receipt, also see null. Nested operations get their own value and restore the outer value. Register a custom `TimeProvider` before or after `AddCratisArcCore()` to control receipt time in tests; Arc uses `TryAddSingleton` for `TimeProvider.System`, so an app registration takes precedence regardless of order. `QueryableQueryRenderer` handles runtime `IQueryable` values: it counts the filtered query, applies sorting, then `Skip`/`Take`. The provider controls database execution. Lists and arrays do not gain automatic slicing. See [model-bound paging](/arc/backend/csharp/queries/model-bound/paging/) or [controller paging](/arc/backend/csharp/queries/controller-based/paging/). Custom renderers implement `IQueryRendererFor` with `QueryRendererResult Execute(T query, QueryContext queryContext)`. They return total count plus data. Do not use an unverified renderer-order assumption as a security boundary; put access filtering before counting and paging so neither rows nor counts disclose unauthorized data. ## Query filters Model-bound filters implement `IQueryFilter.OnPerform(QueryContext)` and return `Task`. Implement `IAuthorizationQueryFilter` for access decisions that must precede ordinary validation filters. Discovered filters are resolved from the operation’s service provider; **authorization filters run first**, then other filters, preserving discovery order within each group. This is not a registration-order guarantee. | Built-in filter | Current behavior | | --- | --- | | `AuthorizationFilter` | Evaluates the query’s authorization declaration: authentication, roles, and registered named policies, plus named schemes on the ASP.NET Core host | | `FluentValidationFilter` | Validates a matching argument model or individual supplied argument graphs | | `DataAnnotationValidationFilter` | Reads annotations on parameter types, not method-parameter attributes or nested DTO properties | Use `QueryResult.Unauthorized(context.CorrelationId)` for denial, not an input-validation error. `QueryResult.Success(...)` permits the next stage; it does not supply the final read data. The [query-health restriction example](/arc/backend/csharp/queries/query-health/#restrict-exposure) is a complete authorization filter that targets one named query across direct model-bound and hub paths. For input rules, follow [query validation](/arc/backend/csharp/queries/validation/). For roles, policies, and schemes on queries, see [model-bound authorization](/arc/backend/csharp/queries/model-bound/authorization/) and [authorization](/arc/backend/csharp/core/authorization/). ## Compose outside `AddCratisArc` If your host builds its own service collection, register the pipeline and its authorization-principal scope alongside your query services. This is a registration fragment for an existing `IServiceCollection` named `services`, not a standalone host: ```csharp using Cratis.Arc.Authorization; using Cratis.Arc.Queries; using Microsoft.Extensions.DependencyInjection; services.AddTransient(); services.AddTransient(); services.AddTransient(); ``` `AuthorizationDeclarations` resolves each query’s authorization declarations, including fallback requirements, from `IInstancesOf<>` of the anonymous, attribute and fallback evaluators, so register Cratis type discovery (`IInstancesOf<>`) as well; without it the pipeline cannot authorize and fails. Supply the pipeline’s constructor services: `ICorrelationIdAccessor`, `IQueryContextManager`, `IQueryFilters`, `IQueryPerformerProviders`, `IQueryRenderers`, `IReadModelInterceptors`, `IDiscoverableValidators`, and `IActivitySource`. Supply `CurrentPrincipalAccessor`, `IAuthorizationPolicyRuntime`, `TenantIdAccessor`, and `ITenantIdResolver` for `AuthorizationPrincipalScope`; the two concrete accessors also need `IHttpRequestContextAccessor` and `ITenantIdResolver`, respectively. Register a runtime appropriate to your host; `ArcAuthorizationPolicyRuntime` does not authenticate named schemes. Pass an execution-scoped `IServiceProvider` to `IQueryPipeline.Perform`. The pipeline resolves `AuthorizationPrincipalScope` only when authorization supplies a selected principal, so a successful ordinary query does not prove that the scope is registered. `Begin` remains internal; host code should not manipulate it directly. If you want all Core services rather than manual composition, `IServiceCollection.AddCratisArcCore()` registers these services and the rest of Arc Core. It does not configure the ASP.NET Core host or map endpoints; see [Core configuration](/arc/backend/csharp/configuration/). ## Query result metadata The backend `Cratis.Arc.Queries.QueryResult` is **nongeneric**, with `object Data`. Generated TypeScript query results can be typed; that does not create a C# `QueryResult` API. Normal query methods return data rather than constructing this infrastructure envelope. | JSON field | Meaning | | --- | --- | | `data` | Returned data; can be null, and can be omitted/null on later hub delta frames | | `paging` | `page` (int), `size` (int), `totalItems` (long), `totalPages` (computed int) | | `correlationId` | Correlation identifier | | `isReady` | Whether a result is available; false is distinct from failure | | `isAuthorized` | Authorization verdict | | `isValid` | True when `validationResults` is empty | | `hasExceptions` | True when `exceptionMessages` is nonempty | | `isSuccess` | `isReady && isAuthorized && isValid && !hasExceptions` | | `validationResults` | Validation entries with severity, message, and members | | `exceptionMessages`, `exceptionStackTrace` | Error information, subject to the transport/host’s exception-detail handling | | `changeSet` | Optional collection delta; see [transfer modes](/arc/backend/csharp/queries/change-stream/) | `PagingInfo` is `PagingInfo(int Page, int Size, long TotalItems)`. `totalPages` is zero for size zero, otherwise `Ceiling(totalItems / size)`. `PagingInfo.NotPaged` is `(0, 0, 0)` and is used for ordinary unpaged pipeline results. Observable snapshots and streaming emissions can instead report the query context’s total count with page and size zero. `totalPages` remains zero when size is zero. There are no `hasNext`, `hasPrevious`, or response `pageSize` fields. Illustrative complete success envelope under camelCase serialization (the identifiers and data are examples, not a captured server response): ```json { "data": [{ "id": "account-1", "name": "Savings", "balance": 100 }], "paging": { "page": 0, "size": 1, "totalItems": 2, "totalPages": 2 }, "correlationId": "12345678-1234-1234-1234-123456789012", "isReady": true, "isSuccess": true, "isAuthorized": true, "isValid": true, "hasExceptions": false, "validationResults": [], "exceptionMessages": [], "exceptionStackTrace": "", "changeSet": null } ``` Default direct model-bound HTTP maps success to 200, authorization denial to 403, validation failure to 400, not-ready to 202, and other failures to 500. Observable waiting also has a 408 timeout path. Hub frames and SSE control POSTs have their own [protocol semantics](/arc/backend/csharp/queries/observable-query-demultiplexer/); do not infer their status from this table. This envelope and this status mapping are part of Arc’s [shared HTTP contract](/arc/http-contract/) and are identical on the JVM backend, down to the precedence between authorization, validation, and readiness. What a client must not assume is identical is covered in [where the implementations differ](/arc/http-contract/#where-the-implementations-differ) - in particular [observable snapshot readiness](/arc/http-contract/#observable-http-snapshot-readiness) and [unknown fields in the QUERY envelope](/arc/http-contract/#unknown-fields-in-the-query-envelope-and-in-query-arguments). ## Streaming boundary An `ISubject>` is not automatically paged: MongoDB `Observe()` implements paging using the context; arbitrary subjects must supply their own behavior. Streaming delivery applies interception per emission, while **observable HTTP snapshots currently do not**. Read [interception limitations](/arc/backend/csharp/queries/read-model-interception/) before relying on masking, and [observable lifetime](/arc/backend/csharp/queries/model-bound/observable-queries/#subscription-lifetime) before composing streams. # Read-model interception > Transform read-model instances and understand where interception is not applied. An interceptor lets you apply a cross-cutting transformation without repeating it in each query. Typical uses include localization, enrichment, and controlled field transformations. It is not a replacement for query authorization or safe data selection. ## Supported paths and exceptions | Path | Interception | | --- | --- | | Ordinary model-bound query data | Applied after rendering | | Ordinary Arc-wrapped MVC GET data | Applied after rendering | | Supported direct WebSocket/SSE streams and multiplexed hub emissions | Applied per emission | | Observable HTTP snapshot, including `waitForFirstResult=true` | Applied before the first allowed result is returned | | MVC `[AspNetResult]` | Opts out of the Arc result-processing path | Caution Interception transforms data; it does not authorize access. Ensure the query/provider selects only rows and fields the caller may receive on **every** exposed path. MVC `[AspNetResult]` still opts out of Arc result processing. ``` Query result Ordinary data Interceptors Stream wrapper Streaming emission HTTP snapshot Client ``` ## Implement an interceptor This type example reuses the shared [`AccountId` concept](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names); the host discovers `IInterceptReadModel` implementations and resolves constructor dependencies from the supplied service provider. This example enriches public display data, rather than using masking as access control. ```csharp using System.Globalization; using System.Threading.Tasks; using Cratis.Arc.Queries; namespace Banking.Accounts; public record AccountSummary(AccountId Id, decimal Balance, string FormattedBalance); public class FormatAccountBalance : IInterceptReadModel { public Task Intercept(AccountSummary readModel) => Task.FromResult(readModel with { FormattedBalance = readModel.Balance.ToString("C", CultureInfo.GetCultureInfo("en-US")) }); } ``` The returned instance is served to the caller. Prefer a `with` copy over mutating a record that another subscriber may share. An interceptor is bound to its exact model type; a DTO with the same fields is not automatically intercepted. ## Multiple interceptors and streams Interceptors run in discovery order. Avoid making security depend on an undocumented ordering between implementations. Collections are processed item by item; streaming paths process each emitted model before delivery. The pipeline skips stream wrappers because a wrapper is not a model instance. Streaming transports and observable HTTP snapshots intercept the emitted values instead, using the requesting subscriber’s identity and scoped services. Interception runs before [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/); a failing interceptor returns an error, never the raw value. Test ordinary GET, observable snapshot GET, direct SSE/WebSocket, and hub delivery separately for any transformation the application depends on. Continue with [observable queries](/arc/backend/csharp/queries/model-bound/observable-queries/) for lifetime rules and [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/) for per-emission access decisions. # Use observable queries with cURL > Read snapshots, wait for a first result, stream direct SSE, and poll without a tight loop. ## Prerequisites Use a running Arc application’s actual observable query URL. The commands below target the explicit `/api/accounts/observe` path from the [model-bound example](/arc/backend/csharp/queries/model-bound/observable-queries/); the same direct-HTTP options work for [MVC GET observable actions](/arc/backend/csharp/queries/controller-based/observable-queries/). Replace the origin and provide your application’s normal credentials. These are runnable client checkpoints against that configured host, not commands that create a backend. Observable HTTP snapshots apply [read-model interception](/arc/backend/csharp/queries/read-model-interception/) and [emission guards](/arc/backend/csharp/queries/observable-query-emission-guards/) before returning data. These do not replace query authorization or safe data selection. ## Get the current snapshot ```bash curl --include --max-time 35 \ 'https://localhost:5001/api/accounts/observe?waitForFirstResult=true' ``` Use the wait option for this MongoDB endpoint: Arc subscribes to receive a first emission, then disposes the subscription. The server’s default wait is 30 seconds; the client bound above is 35 seconds. Caution MongoDB’s `LifetimeAwareSubject` has no readable `Value` property, even after buffering a result. An ordinary no-wait GET therefore returns **202 Accepted** with `isReady: false`, rather than reading that replayed value. The provider starts its watcher eagerly, but this HTTP branch neither subscribes nor disposes the subject and can leave the watcher running. Do not use no-wait snapshots for this provider. For other producers, no-wait GET returns 200 only when Arc can read a current `Value` and the emission guards allow it; a suppressed value or no readable value returns 202. A denied value returns 403 without data. Use that mode only with a readable current value and verified cleanup when no subscription is created. ## Wait for the first payload ```bash curl --include --max-time 15 \ 'https://localhost:5001/api/accounts/observe?waitForFirstResult=true&waitForFirstResultTimeout=10' ``` The server waits for the first allowed emission for up to 10 seconds; the default is 30 seconds. Suppressed emissions do not restart that deadline. A positive timeout override is measured in seconds. `--max-time` bounds the client separately. * An allowed emission returns 200 with intercepted data, including a legitimately empty collection. * Without waiting, a suppressed or unavailable value returns 202 without data. * An emission guard denial or failure returns 403 without data. * A wait timeout returns 408 with an error result. * Completion before any emission returns 500 with an error result. * An authorization/validation failure is not made successful by waiting. This waits for **a first result**, not for a change since a previous request. `ObserveSingle` with no matching document may never emit; a filtered observed collection can express absence as an empty list. ## Stream updates over SSE ```bash curl --no-buffer --max-time 60 \ --header 'Accept: text/event-stream' \ 'https://localhost:5001/api/accounts/observe' ``` This requests the direct per-query SSE transport. Each `data:` frame carries a `QueryResult`, not a hub message envelope. `--max-time 60` deliberately closes the diagnostic stream after a minute; cURL reports a timeout when that bound is reached. Illustrative shortened frames; other result metadata is omitted here only for readability: ```text data: {"isReady":true,"isSuccess":true,"data":[{"id":"account-1","balance":100}],"changeSet":null} data: {"isReady":true,"isSuccess":true,"data":[{"id":"account-1","balance":120}],"changeSet":null} ``` For several queries over one SSE connection, use the [hub GET plus control POST lifecycle](/arc/backend/csharp/queries/observable-query-demultiplexer/#sse-transport). Adding `?query=...` to the hub GET does not subscribe. ## Repeated snapshot polling When SSE is not convenient, poll deliberately with a delay. This bounded example makes five requests and backs off longer on transport/HTTP failure: ```bash for attempt in 1 2 3 4 5; do if curl --silent --show-error --fail-with-body --max-time 20 \ 'https://localhost:5001/api/accounts/observe?waitForFirstResult=true&waitForFirstResultTimeout=15'; then printf '\n' sleep 2 else printf '\nSnapshot request failed; backing off.\n' >&2 sleep 5 fi done ``` This is **repeated snapshot polling, not change-aware long polling**. An unchanged current snapshot can return immediately on every request. Removing the delay creates a tight load-generating loop. Prefer SSE to follow changes continuously. ## Pick the right mode | Goal | Mode | | --- | --- | | Read a MongoDB observable snapshot once | GET with `waitForFirstResult=true` | | Read another producer’s current value without subscribing | Ordinary GET only with a readable current value and verified no-subscription cleanup; handle 202 | | Wait for a first emission | GET with `waitForFirstResult=true` | | Follow one query live | Direct GET with `Accept: text/event-stream` | | Follow several queries on one connection | [Hub protocol](/arc/backend/csharp/queries/observable-query-demultiplexer/) | | Periodically sample state | Delayed, bounded snapshot polling | MongoDB source failures may be logged and completed rather than sent through an observable error channel. Inspect provider logs as well as HTTP output when a feed stops. For producer cleanup, see [subscription lifetime](/arc/backend/csharp/queries/model-bound/observable-queries/#subscription-lifetime). # Use the HTTP QUERY method > Move scalar query arguments into a request body without silently falling back to URLs. Long search text can exceed practical URL limits. Arc’s generated model-bound query endpoints support HTTP `QUERY` with an arguments body as an alternative to GET. It returns the same query result contract; it does not turn a read into a command. Caution If an argument must stay out of URLs, choose **explicit `QueryHttpMethod.Query`**, not `Auto` or the length-based fallback resolver. Auto can retry with GET and put those arguments in the URL. Bodies can also be logged: configure logging/redaction and use HTTPS. Authentication credentials belong in your normal authorization headers or cookies, not query arguments. ## Prerequisites Use a generated **model-bound** endpoint with QUERY enabled and a network path that accepts the verb. Arc does not automatically add QUERY handling to every MVC GET action. Built-in QUERY binding remains [scalar argument conversion](/arc/backend/csharp/queries/model-bound/query-arguments/), not arbitrary nested DTO/array deserialization. ## Declare the transport in CSharp Complete type example for an existing Arc host with a registered MongoDB collection: ```csharp using System.Linq; using Cratis.Arc.Queries; using Cratis.Arc.Queries.ModelBound; using MongoDB.Driver; using MongoDB.Driver.Linq; namespace Catalog; [ReadModel] public record Product(string Id, string Name) { [Path("/api/products/search")] [QueryHttpMethod(QueryHttpMethod.Query)] public static IQueryable Search(string searchText, IMongoCollection collection) => collection.AsQueryable().Where(product => product.Name.Contains(searchText)); } ``` The generated proxy defaults to QUERY. The attribute can also be put on the read-model type, with method-level choice taking precedence. It sets a **client default**, not a server-side ban on GET; both generated verbs remain available. Add [input validation](/arc/backend/csharp/queries/validation/) and authorization appropriate to the data before deployment. ## Opt in from the client Application-startup fragment for all generated queries without an explicit per-query override: ```typescript import { Globals } from '@cratis/arc'; import { QueryHttpMethod } from '@cratis/arc/queries'; Globals.queryHttpMethod = QueryHttpMethod.Query; ``` For a single generated query instance, call `query.setHttpMethod(QueryHttpMethod.Query)` before performing it. Here `query` means an instance of your generated proxy, not a framework singleton. An explicit per-query setting takes precedence over a global resolver and global default. ## Let the framework choose with Auto Use `QueryHttpMethod.Auto` only when the arguments are safe in either transport. It tries QUERY, then retries GET for a 405/501 response or a non-abort fetch/network failure (including CORS failure). It remembers a GET downgrade per backend origin plus API base path for the session. Other HTTP error statuses are not a fallback signal. `resetQueryHttpMethodResolution()` from `@cratis/arc/queries` clears learned transport choices. Explicit QUERY never falls back to GET. ## Choose the transport per query The exported `lengthBasedQueryHttpMethod({ threshold: 2000 })` resolver uses GET for short URLs and Auto for longer ones. It is a compatibility/length heuristic, **not a privacy rule**. Application-startup fragment: ```typescript import { Globals } from '@cratis/arc'; import { lengthBasedQueryHttpMethod } from '@cratis/arc/queries'; Globals.queryHttpMethodResolver = lengthBasedQueryHttpMethod({ threshold: 2000 }); ``` If URLs are prohibited for particular inputs, give those proxies an explicit QUERY setting rather than relying on length. ## The request body For the declared product query, this is the complete request envelope: ```json { "arguments": { "searchText": "widgets" }, "paging": { "page": 0, "pageSize": 20 }, "sorting": { "field": "name", "direction": "asc" } } ``` `paging` and `sorting` are optional. Each argument is converted through the scalar converter; putting an object or array inside `arguments` does not make it bind as a complex parameter. Model-bound readers do not bind route placeholders; use the explicit path and arguments shown here. ## Call it with cURL Runnable against a host exposing the example path, with its actual origin and normal credentials supplied: ```bash curl --include --request QUERY 'https://localhost:5001/api/products/search' \ --header 'Content-Type: application/json' \ --data '{"arguments":{"searchText":"widgets"},"paging":{"page":0,"pageSize":20}}' ``` The response has the same [QueryResult fields](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata) as GET. Generated QUERY responses set `Cache-Control: no-store`; that is not a guarantee that intermediaries or application logs never record the request body. ## Cross-origin and server configuration For browser calls, configure your actual allowed origins, headers/credentials, and methods to include QUERY; preflight must succeed. GET calls with authorization headers or cross-origin credentials may need CORS configuration too. Do not use a blanket wildcard policy as a substitute for an application’s access policy. The server option `ArcOptions.GeneratedApis.EnableQueryHttpMethod` controls registration of generated QUERY endpoints and defaults to enabled. Set it to false to keep only GET. That setting does not change MVC verb declarations. See [configuration](/arc/backend/csharp/configuration/) for host options and [cURL observable workflows](/arc/backend/csharp/queries/using-observable-queries-with-curl/) for snapshot versus streaming reads. # Query validation > Validate model-bound scalar arguments with FluentValidation and distinguish MVC validation. Reject malformed search input before the query reaches storage. Arc’s model-bound query pipeline supports discovered FluentValidation validators; MVC actions use their own model-binding/model-state path. Choose the example for the endpoint style you actually expose. ## Validate the whole argument set A parameter model lets you validate required strings and relationships between scalar arguments without expecting HTTP to bind a complex DTO. This alternative banking declaration uses the [shared `AccountId` and `AccountName` concepts](/arc/backend/csharp/queries/model-bound/#model-account-identities-and-names), the Arc MongoDB provider, and validator discovery. The prefix is search text, so its rule belongs to the query rather than to the complete account-name value: ```csharp using System.Collections.Generic; using System.Text.RegularExpressions; using Cratis.Arc.Queries; using Cratis.Arc.Queries.ModelBound; using FluentValidation; using MongoDB.Bson; using MongoDB.Driver; namespace Banking.Accounts; [ReadModel] public record DebitAccount(AccountId Id, AccountName Name, decimal Balance) { [Path("/api/accounts/search")] public static IEnumerable Search( string prefix, decimal minimumBalance, decimal maximumBalance, IMongoCollection collection) { var namePrefix = new BsonRegularExpression("^" + Regex.Escape(prefix)); var filter = Builders.Filter.Regex(account => account.Name, namePrefix) & Builders.Filter.Gte(account => account.Balance, minimumBalance) & Builders.Filter.Lte(account => account.Balance, maximumBalance); return collection.Find(filter).SortBy(account => account.Id).Limit(100).ToList(); } } public class SearchParameters { public string Prefix { get; set; } = string.Empty; public decimal MinimumBalance { get; set; } public decimal MaximumBalance { get; set; } } public class SearchValidator : QueryValidator { public SearchValidator() { RuleFor(parameters => parameters.Prefix).NotEmpty().Length(3, 50); RuleFor(parameters => parameters.MinimumBalance).GreaterThanOrEqualTo(0); RuleFor(parameters => parameters.MaximumBalance) .GreaterThanOrEqualTo(parameters => parameters.MinimumBalance); } } ``` ```http GET /api/accounts/search?prefix=Sav&minimumBalance=0&maximumBalance=1000 ``` The query lives on the model it returns, so it qualifies for discovery. Its HTTP arguments remain flat. The provider filter searches the serialized `AccountName` field without weakening the domain model; `Regex.Escape` keeps the prefix literal. The 100-row limit is a fixed cap, not automatic paging. `SearchParameters` is materialized internally for validation; the method does not accept a `SearchParameters` HTTP DTO. Missing/short `prefix` or a reversed range fails validation before the method executes. This teaching example still needs [authorization](/arc/backend/csharp/queries/model-bound/authorization/) before exposing private data. ## Argument-model discovery Arc looks in the read model’s assembly for a type named `{QueryName}Parameters` or `{ReadModelName}{QueryName}Parameters`. It must have properties with matching names **and types** covering every caller argument; injected dependencies do not count. Use unambiguous names within that assembly. When a matching model can be materialized, the FluentValidation filter validates it as one object graph. Otherwise it falls back to validating individual supplied arguments. Missing/null individual arguments are skipped by that filter; the performer enforces its required-argument rules. Plain strings are implicitly optional in the current performer, which is why the whole-argument `NotEmpty` rule above matters. Argument-model materialization leaves absent members at their type defaults; do not assume optional method defaults are copied into that validation object. Test omitted values as well as explicit ones. If the model cannot be constructed, fallback validation does not preserve cross-field rules that existed only on that model. ## Concepts and nested validation A `ConceptValidator` defines invariants for a strongly typed value and can apply to supplied query arguments as well as command properties. FluentValidation traverses the object graph, including nested values and collections, and reports failures using the argument/member path. Concept `Value` failures map to the containing field rather than exposing `.Value` to the client. That validation capability does not add HTTP binding support: built-in model-bound GET/QUERY readers use [scalar conversion](/arc/backend/csharp/queries/model-bound/query-arguments/#supported-input-shapes). Already-typed pipeline inputs or explicitly tested custom converters can carry richer values. Generated clients extract supported FluentValidation rules for early feedback. Server validation remains authoritative; custom/async rules are not a blanket promise of browser/server parity. See [proxy validation](/arc/backend/csharp/proxy-generation/validation/). ## DataAnnotations on parameters Apply DataAnnotations such as `[Range(1, 100)]`, `[Required]`, `[StringLength(3)]`, or `[RegularExpression("^[A-Z]+$")]` directly to public static model-bound query parameters. Arc generates validation metadata for annotated parameters and evaluates the attributes against the bound argument. Custom `ValidationAttribute` implementations are supported too. Concept arguments are validated against their underlying value. Validation errors identify the parameter by name; `[Display]` controls its name in error messages. `[Required]` rejects null or omitted values. Other attributes follow DataAnnotations null semantics: an omitted nullable `[Range]` parameter is valid, while a missing non-nullable value type reports that the field is required. This filter does not recursively validate DTO properties. Parameters without generated metadata retain the type-level attribute fallback; method-parameter annotations in assemblies built without Arc’s generator are not evaluated by that fallback. Use FluentValidation for whole-argument and cross-field rules. ## Controller-based validation MVC has separate DataAnnotations/model-state behavior. Use [the complete MVC DTO example](/arc/backend/csharp/queries/controller-based/query-arguments/#bind-and-validate-a-search-dto), where annotations are applied to DTO properties. MVC positional-record validation metadata also differs from ordinary object-property validation; do not mechanically transplant model-bound annotation advice into MVC records. Arc’s GET action filter uses model state and may wrap validation errors. `[ApiController]`, custom filters, and `[AspNetResult]` can change which response runs first or whether it is wrapped. Test the application’s actual HTTP response instead of assuming one universal MVC error envelope. ## Validation results and security A model-bound validation failure has `isValid: false`, `isSuccess: false`, and entries in `validationResults` containing `message`, `members`, and `severity`; members are reported as camelCase field paths, such as `address.street` and `items[0].name`. Direct HTTP maps a validation verdict to 400. The response’s paging fields are `page`, `size`, `totalItems`, and `totalPages`; see [the full QueryResult contract](/arc/backend/csharp/queries/query-pipeline/#query-result-metadata). Validation is not ownership or authorization. Put access decisions in an [authorization verdict/filter](/arc/backend/csharp/queries/query-pipeline/#query-filters), and test forbidden callers independently of invalid input. At minimum test missing, empty, malformed, out-of-range, and cross-field-invalid values through GET, QUERY, and hub subscriptions when those paths are exposed. # Tenancy > Serve multiple customers from one application while keeping their data isolated — how Arc resolves the current tenant and carries it through every request. Tenancy means your application serves multiple customers or organizational units while keeping their data and operations isolated. A tenant could be a customer, a business unit, or any logical boundary that must remain separate from others. ## Why Tenancy Matters Tenancy keeps data and behavior isolated between customers or organizational units while still sharing the same application deployment. Arc provides tenant resolution, tenant context access, and tenant-aware integrations to help you maintain strict separation and predictable behavior. * **Data isolation**: Each tenant should only see its own data, even when sharing infrastructure. * **Compliance**: Many regulatory requirements demand strict separation and auditable access. * **Operational safety**: Isolation reduces the blast radius of mistakes, queries, and deployments. * **Scalability**: Tenancy enables predictable scaling by segmenting traffic and storage by tenant. Keep three decisions separate: **selection** resolves the tenant ID; **membership authorization** checks whether this caller may use it; **storage isolation** determines where reads and writes go. Arc supplies selection and context. The MongoDB integration supplies tenant-aware database naming; EF Core isolation is application-owned. Optional Chronicle integration selects event-store namespaces. None of these replaces membership checks. ``` Request Resolve tenant ID Application verifies membership Integration-specific storage isolation ``` See [database isolation](/arc/backend/csharp/tenancy/database-resolvers/) for the exact integration boundaries, or [tenancy end to end](/arc/backend/csharp/tenancy/end-to-end/) to follow a tenant from request to storage across MongoDB, EF Core and Chronicle. ## Best Practices * Choose a resolver that aligns with your authentication and request flow. * Validate that the requester is authorized to access the resolved tenant. * Include the tenant ID in cache keys, logs, and telemetry. * Keep tenant IDs stable and opaque to avoid enumeration. * Prefer tenant-aware data stores and avoid cross-tenant queries. * Use the development resolver only in local or test environments. ## Security Considerations * Ensure tenant ID resolution happens only after authentication. * Enforce tenant membership checks in application services and policies. * Log tenant access for audits and incident investigation. * Prevent tenant ID spoofing by validating headers, claims, and parameters. * Treat tenant ID as sensitive metadata and avoid exposing it unnecessarily. ## Topics * [Tenancy end to end](/arc/backend/csharp/tenancy/end-to-end/): defaults, connection resolution and Chronicle in one walkthrough * [Resolving tenant IDs](/arc/backend/csharp/tenancy/resolvers/) * [Configuration](/arc/backend/csharp/tenancy/configuration/) * [Tenant context access](/arc/backend/csharp/tenancy/tenant-context/) * [Database isolation](/arc/backend/csharp/tenancy/database-resolvers/) * [Development tenant discovery with `ICanProvideTenants`](/arc/backend/csharp/identity/development-and-topologies/#implementing-a-tenants-provider) (C#), or the equivalent [`TenantsProvider`](/arc/backend/kotlin/guides/development-users-and-tenants/) (Kotlin and Java) — the `/.cratis/tenants` endpoint that development tools such as [Lens](/tools/lens/) use to let you switch tenants without touching configuration. # Tenancy Configuration > Configure tenant resolution in code or configuration files for Arc.Core and ASP.NET Core hosts. You can configure tenancy programmatically or through configuration files. Both approaches map to the same options and resolver types. ## Programmatic configuration This is a configuration fragment for an existing `ArcApplicationBuilder` (Core) or `WebApplicationBuilder` (ASP.NET Core), before `Build()`. Import `Cratis.Arc` and `Cratis.Arc.Tenancy`; each host uses `builder.AddCratisArc(...)`, followed by `app.UseCratisArc()` on the built app. ```csharp builder.AddCratisArc(options => { options.UseHeaderTenancy("X-Custom-Tenant"); // options.UseQueryTenancy("tenant"); // options.UseClaimTenancy("tenant_id"); // options.UseSubdomainTenancy("myapp.com", "X-Custom-Tenant"); // options.UseFixedTenancy("acme"); // options.UseDevelopmentTenancy("test-tenant"); }); ``` ## Configuration File (appsettings.json) ```json { "Cratis": { "Arc": { "Tenancy": { "ResolverType": "Header", "HttpHeader": "X-Custom-Tenant" } } } } ``` ## Resolver-specific settings The following JSON fragments belong inside `Cratis:Arc`, not at the root of `appsettings.json`. The [complete options tree](/arc/backend/csharp/configuration/) lists defaults. Resolver configuration selects a tenant; [membership authorization and storage isolation](/arc/backend/csharp/tenancy/) remain separate responsibilities. ### Header Resolver ```json { "Tenancy": { "ResolverType": "Header", "HttpHeader": "X-Custom-Tenant" } } ``` ### Query Resolver ```json { "Tenancy": { "ResolverType": "Query", "QueryParameter": "tenant" } } ``` ### Claim Resolver ```json { "Tenancy": { "ResolverType": "Claim", "ClaimType": "tenant_id" } } ``` ### Subdomain Resolver `BaseDomain` is the domain the application itself is served from. A host resolves a tenant only when it is exactly one label in front of it; every other host falls back to `HttpHeader`. `BaseDomain` is **required** and must be the registrable domain you own — at least two letter-digit-hyphen labels, never an address literal. Leaving it out, or setting it to something no host could be matched against, throws `BaseDomainIsNotADomainName` and the host does not start — `UseSubdomainTenancy` throws where you call it, and a value that arrives from configuration is validated while the host is starting. Neither one lets a request through to take its tenant from the client-supplied `HttpHeader` instead. See [the subdomain resolver](/arc/backend/csharp/tenancy/resolvers/) for the full rules. ```json { "Tenancy": { "ResolverType": "Subdomain", "BaseDomain": "myapp.com", "HttpHeader": "X-Custom-Tenant" } } ``` ### Fixed Resolver ```json { "Tenancy": { "ResolverType": "Fixed", "FixedTenantId": "acme" } } ``` ### Development Resolver ```json { "Tenancy": { "ResolverType": "Development", "DevelopmentTenantId": "local-tenant" } } ``` `FixedTenantId` and `DevelopmentTenantId` are two names for the same value, so either key configures either resolver type. Set only one of them - when a configuration source supplies both, whichever key the binder visits last wins. # Connection resolution > Choose per tenant which server, database and identity a MongoDB or Entity Framework Core connection uses, from configuration, a template, an endpoint or code. Your platform places each tenant’s database somewhere: a server, a database name, maybe its own credentials or a short-lived access token. With one configured connection string, Arc cannot follow that placement. MongoDB only appends the tenant to a database name, and Entity Framework Core uses the same connection for everyone. Connection resolution lets Arc ask instead. When a connection is needed, Arc describes **what** it connects for (the storage, the tenant and the purpose), and a resolver answers with the parts it knows. Everything it leaves out stays as configured. ## Two slots, one shape Resolution has two optional slots, and both take the same kind of resolver: * **`Connection`**: where to connect. This is the connection string, the database and the schema. * **`Identity`**: who to connect as. This is a user name and password, or an access token. Arc starts from the configured connection, applies what the `Connection` slot resolves, and then applies what the `Identity` slot resolves. A part set later wins. This is what makes the common split a configuration choice: keep the connection string from configuration and fetch only the identity from your platform. The reverse works too, and so does resolving both. ``` Configured connection Connection slot Identity slot Connection that opens ``` With no slot configured, nothing changes: Arc uses the configured connection exactly as before. ## Choose a resolver | Resolver | Use it when | | --- | --- | | `Default` | Nothing needs resolving. This is the default for both slots. | | `Template` | The placement follows a naming rule, such as `Database=app-{tenant}`. It also covers passwordless Entra ID authentication written into the connection string. | | `Http` | Your platform decides placement or hands out credentials, and you can call it over HTTP. | | `Custom` | You need code, such as a secret store or an SDK. Your type implements `IConnectionResolver` and is created through dependency injection. | A template may use any scope value in braces (`{tenant}`), as well as `{purpose}` and `{storage}`. A placeholder with no value fails instead of connecting somewhere unintended. ## What a resolver answers ```csharp public record ResolvedConnection( string? ConnectionString = null, string? Database = null, string? Schema = null, ConnectionIdentity? Identity = null, DateTimeOffset? ExpiresAt = null); public record ConnectionIdentity(string? UserName = null, string? Password = null, string? AccessToken = null); ``` Every part is optional, and `ResolvedConnection.Unchanged` means “keep everything as configured”. The identity is replaced as a whole, so a password from one slot never ends up next to an access token from the other. A custom resolver is one method: ```csharp using Cratis.Arc.Connections; public class TenantConnections(ITenantDirectory directory) : IConnectionResolver { public async Task Resolve(ConnectionRequest request, CancellationToken cancellationToken = default) { var tenant = request.Scope[WellKnownConnectionScopeKeys.Tenant]; var placement = await directory.Find(tenant, cancellationToken); return new(Database: placement.Database, ExpiresAt: placement.ValidUntil); } } ``` `ITenantDirectory` stands in for your own service. Select the resolver in code; Arc creates it through dependency injection, so its constructor can take any registered service: ```csharp builder.AddCratisArc(configureBuilder: arc => arc.WithMongoDB( configureOptions: mongo => mongo.ConnectionResolution.Connection.UseCustom())); ``` ## The HTTP contract The `Http` resolver posts what it connects for (`storage`, `purpose` and a `scope` holding the `tenant`) as JSON to your endpoint, and your endpoint answers with the parts it knows. The contract is plain REST and the same for MongoDB and every SQL storage, so you can serve it from any language. The [connection resolution contract](/arc/connection-resolution-contract/) specifies the request, the response, the status codes, caching and security, and includes an OpenAPI description. Configure static headers, such as an API key, under `Http:Headers`. The default timeout is 10 seconds (`Http:Timeout`). ## Authenticate to the endpoint The resolver can call your endpoint with an OAuth 2.0 bearer token instead of, or next to, a static API key. Configure it under `Http:Authentication`, and every request carries `Authorization: Bearer `: | Setting | Description | | --- | --- | | `Type` | `None` (the default), `ClientCredentials` or `AzureCredential`. | | `Scope` | The scope the token is requested for. For Microsoft Entra ID this is the endpoint’s application ID URI with `/.default`, such as `api:///.default`. | | `TokenEndpoint` | The OAuth 2.0 token endpoint, for `ClientCredentials`. | | `ClientId` | The client id. For `ClientCredentials` it identifies your application; for `AzureCredential` it selects a user-assigned managed identity or the workload identity application. | | `ClientSecret` | The client secret, for `ClientCredentials`. Keep it in a secret store. | | `Credential` | For `AzureCredential`: `ManagedIdentity` (the default), `WorkloadIdentity` or `Default`, the default Azure credential chain that also covers a developer sign-in. | | `RefreshMargin` | How long before it expires a token is renewed. Five minutes by default. | Arc keeps the token in memory, reuses it until the refresh margin, and fetches one token at a time however many connections ask for it. When your endpoint answers `401 Unauthorized`, Arc drops the token, gets a fresh one and sends the request once more. A `403 Forbidden` is not retried. Tokens and secrets are never logged. Configuring both `Authentication` and an `Authorization` header under `Http:Headers` is a configuration error, and so is selecting a `Type` nothing can provide; both fail when the resolver is created rather than on the first connection. ### Client credentials `TokenEndpoint` must use HTTPS; HTTP is allowed only for loopback hosts (such as `localhost`, `127.0.0.1` or `::1`) during local development. `ClientCredentials` works with any OAuth 2.0 provider and needs nothing beyond Arc: ```json { "Cratis": { "Arc": { "ConnectionResolution": { "Identity": { "Resolver": "Http", "Http": { "Endpoint": "https://platform.example/connections", "Authentication": { "Type": "ClientCredentials", "TokenEndpoint": "https://login.microsoftonline.com//oauth2/v2.0/token", "ClientId": "", "ClientSecret": "from-your-secret-store", "Scope": "api:///.default" } } } } } } } ``` ### Microsoft Entra ID with a managed identity On Azure, let a managed identity or workload identity sign in, so there is no secret to keep. Add the `Cratis.Arc.Connections.Azure` package and register it: ```csharp builder.Services.AddAzureConnectionResolverAuthentication(); ``` Then select `AzureCredential`. The client id below selects a user-assigned managed identity; leave it out to use the system-assigned identity. As environment variables: ```bash Cratis__Arc__ConnectionResolution__Identity__Resolver=Http Cratis__Arc__ConnectionResolution__Identity__Http__Endpoint=https://platform.example/connections Cratis__Arc__ConnectionResolution__Identity__Http__Authentication__Type=AzureCredential Cratis__Arc__ConnectionResolution__Identity__Http__Authentication__Credential=ManagedIdentity Cratis__Arc__ConnectionResolution__Identity__Http__Authentication__ClientId= Cratis__Arc__ConnectionResolution__Identity__Http__Authentication__Scope=api:///.default ``` Without the package registered, `AzureCredential` fails at startup and tells you to call `AddAzureConnectionResolverAuthentication()`. To get tokens some other way, implement `IConnectionResolverAccessTokenProvider` and register it in the service collection. Your endpoint must validate the token; see [Security](/arc/connection-resolution-contract/#security) in the contract. ## Caching and credentials Arc keeps each resolution **in memory only**, keyed by storage, purpose and tenant. It never keys or logs by credential. An entry lives until its `ExpiresAt`, renewed one minute early, or for `CacheDuration` (five minutes by default) when the resolver gave no expiry. Set `CacheDuration` to `00:00:00` to resolve on every connection. When a connection fails to authenticate, Arc drops the cached entry, so the next attempt resolves again and picks up rotated credentials. The failed attempt itself is not retried. ## Purpose Connections resolve for `Runtime` unless code says otherwise. Run schema changes inside a purpose scope to have them resolve, for example, a user that may create tables: ```csharp using (ConnectionPurposeScope.Begin(ConnectionPurpose.Schema)) { await dbContext.Database.MigrateAsync(); } ``` ## Configure it once for the whole application Configure resolution on Arc itself, under `Cratis:Arc:ConnectionResolution`, and both MongoDB and Entity Framework Core use it: ```json { "Cratis": { "Arc": { "ConnectionResolution": { "Identity": { "Resolver": "Http", "Http": { "Endpoint": "https://platform.example/connections", "Headers": { "X-Api-Key": "from-your-secret-store" } } } } } } } ``` The same in code: ```csharp builder.AddCratisArc(configureOptions: arc => arc.ConnectionResolution.Identity.UseHttp(new Uri("https://platform.example/connections"))); ``` The `storage` in each request tells your endpoint which storage is asking, so one endpoint can serve both. An integration that configures its own `ConnectionResolution` uses that instead, as a whole: there is no merging between the application-wide and the integration’s configuration. Use it when one storage needs a different endpoint. ## Configure it for one storage * [MongoDB connection resolution](/arc/backend/csharp/mongodb/connection-resolution/) * [Entity Framework Core connection resolution](/arc/backend/csharp/entity-framework/connection-resolution/) Connection resolution chooses a destination; it does not authorize the tenant. Keep rejecting tenants a caller may not use before any storage is touched, as described in [database isolation](/arc/backend/csharp/tenancy/database-resolvers/). # Database isolation > Distinguish MongoDB tenant naming from application-owned EF Core isolation and optional Chronicle namespaces. For a side-by-side walkthrough of all three integrations, see [tenancy end to end](/arc/backend/csharp/tenancy/end-to-end/). Resolving a tenant ID selects a context. Whether that context selects separate storage depends on the integration; it does not prove that the caller may access the tenant. ## MongoDB database naming With the optional Arc MongoDB integration, `DefaultMongoDatabaseNameResolver` uses: ```text {BaseDatabaseName} // TenantId.NotSet or TenantId.Default {BaseDatabaseName}+{TenantId} // any other tenant ``` For a base name `MyDatabase`, both `TenantId.NotSet` and `TenantId.Default` select `MyDatabase`; tenant `acme-corp` selects `MyDatabase+acme-corp`. `TenantId.IsDefault` covers both default aliases. Preserve that behavior in custom resolvers unless you intentionally migrate to different naming. To place tenants on other servers or databases, or give them their own credentials, use [connection resolution](/arc/backend/csharp/tenancy/connection-resolution/) instead of a custom name resolver. Database naming separates destinations; it does not check membership or database permissions. Reject unauthorized tenant selections before accessing tenant-scoped services, and avoid singleton capture of tenant-scoped collections. See [MongoDB tenancy](/arc/backend/csharp/mongodb/tenancy/). ## Entity Framework Core database naming Arc’s EF Core registration uses the configured connection string, including in pooled context registration. It does **not** automatically append the tenant ID or create a database per tenant. To connect each tenant to its own database, server or identity, configure [connection resolution](/arc/backend/csharp/tenancy/connection-resolution/); Arc then resolves the connection for the current tenant every time it opens. Choose and implement relational isolation in the application: separate connections/databases, schemas, or appropriately enforced tenant filtering. Verify tenant selection, all read/write paths, and pooled-context reuse for that design. A tenant context by itself does not make an ordinary `DbContext` tenant-aware. See [EF Core configuration](/arc/backend/csharp/entity-framework/automatic-database-hookup/); this page does not prescribe an untested pooled-context isolation recipe. ## Chronicle event store namespace Only when you enable the optional [Chronicle integration](/arc/backend/csharp/chronicle/tenancy/) does Arc’s tenant context select a Chronicle event-store namespace. That is separate from MongoDB/EF Core storage behavior and is not a standalone Arc requirement. ## Verify the boundary Test unset, named default, and two named tenants, including concurrent requests and reused scopes/pools. Verify both permission rejection and the actual storage destination. Test caches and background execution too; storage naming alone cannot prevent cross-tenant data from a wrongly reused service instance. # Tenancy end to end > Follow one tenant from request to storage with MongoDB, Entity Framework Core and Chronicle, what happens by default, and how connection resolution changes it. A tenant passes through three separate decisions before any data is read or written. Arc and the storage integrations each own part of it, and the defaults differ per storage. This page follows one tenant through all of them. ``` Request 1. Selection: resolve the tenant id 2. Membership: your code allows it 3. Placement: which database and identity ``` | Decision | Owner | Details | | --- | --- | --- | | **Selection** | Arc | [Resolving tenant IDs](/arc/backend/csharp/tenancy/resolvers/) from a header, claim, subdomain or your own resolver | | **Membership** | Your application | Reject tenants the caller may not use, before any storage is touched | | **Placement** | The storage integration | [Naming by default](#what-you-get-by-default), or [connection resolution](/arc/backend/csharp/tenancy/connection-resolution/) when your platform decides | Arc resolves a tenant and exposes it. It does not authorize it. That check is yours. ## What you get by default Resolve the tenant from a claim and nothing else needs configuring for selection: ```json { "Cratis": { "Arc": { "Tenancy": { "ResolverType": "Claim", "ClaimType": "tenant_id" } } } } ``` Each storage then reacts differently to the same tenant: | Storage | What a named tenant `acme` gets | Default or unset tenant | | --- | --- | --- | | **MongoDB** | The database `+acme`. Collections in that scope come from it | The bare `` | | **Entity Framework Core** | **Nothing.** Every tenant uses the one configured connection string. Isolation is yours to build | The same connection | | **Chronicle** | The event store namespace `acme`, used verbatim | The `Default` namespace | MongoDB and Chronicle separate tenants for you. EF Core does not, which is the usual surprise. A tenant context alone does not make an ordinary `DbContext` tenant-aware. Unset and default tenants deliberately select the default database or namespace and are not rejected. If your application must not serve data without a tenant, reject the request yourself. ### Reading the tenant yourself Inject `ITenantIdAccessor` where a service needs to branch on the tenant. This reads context only and does not authorize it: ```csharp using Cratis.Arc.Tenancy; public class CustomerService(ITenantIdAccessor tenantIdAccessor) { public bool HasTenant => tenantIdAccessor.Current != TenantId.NotSet; } ``` For off-request work, select the tenant first as described in [tenant context access](/arc/backend/csharp/tenancy/tenant-context/). ## Let your platform decide placement Default naming suits a team that owns its database server. When a platform provisions each tenant’s database, on its own server and with its own credentials, switch to [connection resolution](/arc/backend/csharp/tenancy/connection-resolution/). When a connection is needed, Arc describes **what** it is for (the storage, the tenant and the purpose), and a resolver answers with the parts it decides. Resolution has two slots with one shape: `Connection` decides where to connect, and `Identity` decides who connects. Nothing changes until you configure one. ### One naming rule for every storage This places every tenant in a database named after it, for MongoDB and EF Core alike: ```json { "Cratis": { "Arc": { "ConnectionResolution": { "Connection": { "Resolver": "Template", "Template": { "Database": "tenant-{tenant}" } } } } } } ``` ### Credentials from your platform Keep the server and database as configured, and resolve only who connects. A short-lived token from your platform stays out of configuration: ```json { "Cratis": { "Arc": { "ConnectionResolution": { "Identity": { "Resolver": "Http", "Http": { "Endpoint": "https://platform.example/connections", "Headers": { "X-Api-Key": "from-your-secret-store" } } } } } } } ``` The request carries the `storage`, so one endpoint can serve MongoDB and EF Core. It is a plain REST contract you can implement in any language: see the [connection resolution contract](/arc/connection-resolution-contract/). ### Placement by storage The resolved parts apply differently to each storage: | | MongoDB | SQL Server | PostgreSQL | | --- | --- | --- | --- | | `ConnectionString` | Replaces `Server` | Replaces the connection string | Replaces the connection string | | `Database` | Replaces the database name, **including for the default tenant** | `Initial Catalog` | `Database` | | `Identity` password | A SCRAM credential | `User ID` and `Password` | `Username` and `Password` | | `Identity` access token | A `MONGODB-OIDC` credential that refreshes through the resolver | `SqlConnection.AccessToken` (Entra ID) | Used as the password | | `Schema` | Not used | Not applied | Not applied | Two nuances matter most: * **A resolved `Database` replaces the default naming completely.** Tenant `acme` is no longer `+acme`, and the default tenant resolves as `Default`. Data stored under the old names stays there, so treat switching as a data migration. * **EF Core resolves when a connection opens, not when a context is created.** A pooled context never reuses a connection resolved for an earlier tenant. See [MongoDB](/arc/backend/csharp/mongodb/connection-resolution/) and [Entity Framework Core](/arc/backend/csharp/entity-framework/connection-resolution/) for each storage. A storage that configures its own `ConnectionResolution` replaces the application-wide one as a whole. They are not merged. ### Migrations as a different identity Resolution is for the `Runtime` purpose unless code says otherwise. Run schema changes inside a purpose scope so your resolver can hand out an identity that may alter the schema: ```csharp using (ConnectionPurposeScope.Begin(ConnectionPurpose.Schema)) { await dbContext.Database.MigrateAsync(); } ``` ## Chronicle: the kernel decides its own placement The Chronicle integration maps the tenant to a namespace and nothing more. Where the namespace’s events and read models live is decided by the Chronicle **kernel**, which is configured on its own, with the same resolvers and the same REST contract: * Arc’s `Cratis:Arc:ConnectionResolution` does not change where the kernel stores anything. * The kernel’s `storage.connectionResolution` does not change what your Arc application connects to. * A single platform endpoint can answer both. Arc sends the tenant as its scope, and the kernel sends `kind`, `eventStore` and `namespace`. Keep them aligned when your application reads read models Chronicle writes. Chronicle projects into its read model database for the namespace. Your application reads through MongoDB or EF Core and its own resolution. Both must reach the same database. By default MongoDB lines up: Arc’s `+` equals Chronicle’s read model name when `Database` is the event store name, and both use the bare name for the default tenant. With resolution, point both at the same placement. Reads that reach the wrong database come back empty rather than failing. Read Chronicle’s [Tenancy](/chronicle/tenancy/) for the kernel side, including how MongoDB and SQL differ in naming, limits and permanent bindings. ## Choose a setup | You want | Use | | --- | --- | | One database server, a database per tenant named by convention | The defaults for MongoDB and Chronicle. Add your own isolation for EF Core | | Every tenant in a database named by a rule | A `Template` resolver on Arc, and the same rule on the kernel | | Tenant databases provisioned by a platform, on separate servers | An `Http` resolver on Arc and on the kernel | | Rotating credentials, managed identities or short-lived tokens | An `Identity` slot only, on both | | Migrations as a more privileged user | `ConnectionPurpose.Schema`, and a resolver that answers it | ## Checklist * Verify membership before selecting a tenant, because Arc selects but does not authorize. * Do not capture tenant-scoped collections, contexts or services in singletons. * Include the tenant id in cache keys, logs and telemetry. * Test an unset tenant, the default tenant and two named tenants, including concurrent requests and reused scopes. * Check where each tenant’s data actually lands, and not only that the tenant id was resolved. ## Where next * [Database isolation](/arc/backend/csharp/tenancy/database-resolvers/): what each integration isolates * [Connection resolution](/arc/backend/csharp/tenancy/connection-resolution/): the resolvers and the slots * [Chronicle tenancy](/arc/backend/csharp/chronicle/tenancy/): mapping the tenant to a namespace * [Chronicle’s Tenancy overview](/chronicle/tenancy/): the kernel, MongoDB and SQL in depth # Resolving Tenant IDs > Reference for Arc's built-in tenant ID resolvers (header, query, claim, subdomain, fixed, and development). Arc resolves tenant IDs through pluggable strategies. Each request is evaluated by the configured resolver, and the resulting tenant ID becomes the active tenant context for the request lifecycle. ## Built-in resolvers Code below is configuration fragments for an existing `ArcApplicationBuilder` or `WebApplicationBuilder`; import `Cratis.Arc` and `Cratis.Arc.Tenancy`. Register before `Build()` and activate the resulting app with `UseCratisArc()`. The separate `IHostBuilder.AddCratisArcCore(...)` overload is for generic-host wiring, not these builders. All resolvers **select**, rather than authorize, a tenant. Validate membership against the authenticated principal and authoritative data before using tenant-scoped storage. Caller-controlled headers, query strings, and hosts are not membership evidence; claim-based selection is only as trustworthy as the authentication that supplied the claim. ### Header Resolver (Default) Resolves the tenant ID from an HTTP header. ```csharp builder.AddCratisArc(options => { options.UseHeaderTenancy("X-Custom-Tenant"); }); ``` Default header name: `x-cratis-tenant-id` ### Query Parameter Resolver Resolves the tenant ID from a query string parameter. ```csharp builder.AddCratisArc(options => { options.UseQueryTenancy("tenant"); }); ``` Default parameter name: `tenantId` ### Claim Resolver Resolves the tenant ID from a claim on the request’s user. The resolver does not check that the user is authenticated, and it does not check that the user belongs to that tenant: authentication middleware must run first, and membership is a separate authorization rule. ```csharp builder.AddCratisArc(options => { options.UseClaimTenancy("tenant_id"); }); ``` Default claim type: `tenant_id` Run `app.UseAuthentication()` before middleware that needs a claim-based tenant. Middleware added through `IStartupFilter` can run before authentication configured in `Program.cs`; an early read without a tenant claim returns `TenantId.NotSet` but is not cached, so a later read after authentication can resolve the tenant. Resolved tenants remain cached for the execution flow. This does not defer work already performed without a tenant, so tenant-dependent work must still wait until authentication has run. ### Subdomain Resolver Resolves the tenant ID from the subdomain of a configured base domain, and falls back to the configured HTTP header for every host that does not carry one. ```csharp builder.AddCratisArc(options => { options.UseSubdomainTenancy("myapp.com", "X-Custom-Tenant"); }); ``` A host carries a tenant only when it is **exactly one label in front of the base domain**. A request to `acme.myapp.com` resolves the tenant as `acme`. A request to `myapp.com` falls back to the `X-Custom-Tenant` header. This pattern is useful for SaaS applications where each tenant is routed through its own subdomain. The number of labels in a host is never used to decide whether it carries a tenant, so the following all fall back to the header: | Host | Resolved tenant | | --- | --- | | `acme.myapp.com` | `acme` | | `myapp.com` (the base domain itself) | the header | | `acme.staging.myapp.com` (more than one label) | the header | | `10.0.0.5`, `[::1]` (IP literals) | the header | | `otherapp.com` (an unrelated domain) | the header | | `evil-myapp.com` (ends with the same text, no label boundary) | the header | | `acme.myapp.com.evil.com` (base domain in the middle) | the header | | `*.myapp.com`, `user@acme.myapp.com`, `a_b.myapp.com` (not a DNS label) | the header | Hosts are normalized before matching, so a trailing dot, a port, mixed casing and an internationalized name all resolve the same tenant: `ACME.MyApp.com.`, `acme.myapp.com:5000` and `acme.myapp.com` all resolve `acme`, and `münchen.myapp.com` resolves the punycode label `xn--mnchen-3ya` — the same tenant as `xn--mnchen-3ya.myapp.com`. Use the base domain to decide what your application’s own host is. If the application is served from `www.myapp.com`, configure that as the base domain and `www.myapp.com` falls back to the header while `acme.www.myapp.com` resolves `acme`. With `myapp.com` as the base domain, `www` is an ordinary tenant label like any other — no host name is treated as special. Default fallback header: `x-cratis-tenant-id` #### The base domain is required The base domain is what separates a tenant host from any other host on the internet, so your application refuses to start without a usable one. `UseSubdomainTenancy` throws `BaseDomainIsNotADomainName` when the value is empty, is a single label, is an address literal, or is not made of letter-digit-hyphen labels: ```csharp options.UseSubdomainTenancy("myapp.com"); // fine options.UseSubdomainTenancy("X-Tenant-Id"); // throws — a header name is not a domain options.UseSubdomainTenancy("localhost"); // throws — a single label is not a registrable domain options.UseSubdomainTenancy("192.168.1.10"); // throws — an address identifies no domain ``` Selecting the subdomain resolver in `appsettings.json` gets the same treatment: Arc validates `Tenancy` while the host is starting, so `"ResolverType": "Subdomain"` with a missing or unusable `BaseDomain` throws the same exception out of host startup, before the application accepts its first request. Failing to start is deliberate. Without a base domain no host would ever resolve a tenant, and every request would silently take its tenant from the fallback header instead — which any client can set. Failing on the first request instead would mean a real user finds the mistake, on a process that already reported itself healthy. **Configure the registrable domain your application is served from, and nothing broader.** A bare top-level domain such as `com` is refused because it is a single label, but the check cannot know that `co.uk` is a public suffix: with `co.uk` as the base domain, anyone who registers `evil.co.uk` becomes the tenant `evil`. Pick the domain you actually own. #### The resolved tenant must be a DNS label The resolved label can become part of a MongoDB database name or, with optional Chronicle integration, an event-store namespace, so it is required to be a valid letter-digit-hyphen label — up to 63 characters, starting and ending with a letter or digit. Anything else falls back to the header rather than travelling on as a tenant ID. #### One domain, many spellings Hosts are matched after IDNA compatibility mapping, which is what lets `münchen.myapp.com` and `xn--mnchen-3ya.myapp.com` be the same tenant. The same mapping means several byte sequences are **the same host**, and all resolve the tenant `admin`: | Written as | Why it is the same | | --- | --- | | `admin.myapp.com` | the canonical spelling | | `admin.myapp.com` | fullwidth Latin letters map to ASCII | | `admin.myapp.com.` | a root-anchored name | | `admin。myapp.com`, `admin.myapp.com`, `admin。myapp.com` | U+3002, U+FF0E and U+FF61 are label separators | | `admin.myapp.com` | zero width space, soft hyphen, byte order mark and word joiner are ignorable | This is IDNA working as specified — browsers resolve these the same way — so Arc does not reject them. Be aware of the consequence: **a WAF, ingress or router that matches the literal `Host` string sees a different value than Arc does.** If something upstream makes decisions per tenant host, normalize the host there too, or make the decision from Arc’s resolved tenant rather than from the raw header. #### The fallback header is client-supplied Every host that does not carry a tenant falls back to `HttpHeader`, and that header arrives on the request unauthenticated — any caller can set it. **Strip the fallback header at your ingress** so only your own infrastructure can set it, exactly as you would for any other trusted request header. ### Fixed Resolver Resolves every request to one configured tenant ID. ```csharp builder.AddCratisArc(options => { options.UseFixedTenancy("acme"); }); ``` Default tenant ID: `development` The tenant ID is returned regardless of the request and regardless of the hosting environment, which makes this the resolver for a single-tenant deployment: one deployment, one tenant, decided at configuration time rather than per request. When the relevant integrations are enabled, the resolved tenant drives the Chronicle namespace or Arc MongoDB database. A fixed tenant is a deployment-wide data-isolation decision — review it before promoting a configuration. ### Development Resolver The same behavior as the Fixed resolver, under its original name. ```csharp builder.AddCratisArc(options => { options.UseDevelopmentTenancy("my-test-tenant"); }); ``` Default tenant ID: `development` Despite the name, this resolver has never consulted `IHostEnvironment` - it returns the configured tenant ID in every environment, production included. Prefer `UseFixedTenancy` when the fixed tenant is a deployment constant rather than a local-development convenience; `UseDevelopmentTenancy` remains supported and configures the same tenant ID. # Tenant Context Access > Read the resolved tenant through dependency injection, and carry a tenant into background work deliberately. Once a tenant ID is resolved, Arc exposes the current tenant context through dependency injection. This is selection, not proof that the caller is a member. Background operations also need an intentional tenant execution context; use [an explicit tenant scope](/arc/backend/csharp/commands/command-pipeline/#execute-for-a-specific-tenant) and create the DI scope for tenant-scoped services inside it. Do not capture a request-scoped storage service in a singleton. Explicit scopes require Arc’s `TenantIdAccessor` to be the effective `ITenantIdAccessor`; a custom accessor registered before or after Arc remains effective, but resolving `ITenantScope` then throws `ExplicitTenantScopeRequiresArcAccessor` instead of silently selecting a different tenant. ## Accessing the Current Tenant ```csharp using Cratis.Arc.Tenancy; public class CustomerService(ITenantIdAccessor tenantIdAccessor) { public async Task HandleAsync() { var tenantId = tenantIdAccessor.Current; if (tenantId == TenantId.NotSet) { return; } await LoadTenantDataAsync(tenantId); } private static Task LoadTenantDataAsync(TenantId tenantId) => Task.CompletedTask; } ``` The fragment illustrates reading context only; `LoadTenantDataAsync` does not actually load or authorize data. Production code must enforce membership before accessing storage. ## TenantId.NotSet `TenantId.NotSet` signals that no tenant could be resolved from the current context. Handle this case explicitly so you avoid mixing tenant-aware and non-tenant operations. `TenantId.Default` is the named default, and `IsDefault` includes both `NotSet` and `Default`; Arc’s default MongoDB resolver maps both to the same unsuffixed database. Checking only `NotSet` intentionally distinguishes those values and is not a complete default-storage check. # Testing > Test standalone Arc commands through the real pipeline, with optional Chronicle event-sourcing support. A useful spec answers a precise question: is the decision right, did Arc enforce the rule, or did the real system persist and serve the intended result? Cratis lets you test each of those without forcing every case through the widest—and slowest—boundary. A command’s `Handle()` is an ordinary C# method. Call it directly for a deterministic decision; use `CommandScenario` when the framework’s composition is part of what you need to prove. Use [query scenarios](/arc/backend/csharp/testing/query-scenario/) for static read-model queries that need Arc’s binding, filters, or authorization, and [observable query scenarios](/arc/backend/csharp/testing/query-scenario/#observable-queries) to assert initial and subsequent emissions. Add hosted or provider-backed tests for boundaries neither can cover. ## Choose the boundary that can catch the bug | What you need to prove | Start with | What it does not prove | | --- | --- | --- | | A deterministic calculation or decision | Direct `Handle()` spec with explicit input/state | Arc validation, authorization, `Provide()`, or persistence | | Calls to an application service | Direct unit spec with substituted collaborators | The real service, database, or production registration | | Validation, authorization, `Provide()`, dependencies, or command responses | `CommandScenario` | HTTP routing/authentication middleware or external infrastructure | | Model-bound query binding, validation, authorization, or buffered observable emissions | `QueryScenario` | HTTP routing, scheme authentication, streaming transports, or real database watchers | | Returned operations execute and compensate in the correct order | `CommandScenario` with controlled provider dependencies | Production provider idempotency, terminal cancellation, or crash recovery | | Returned events use the intended source and append contract | `CommandScenario` with the Chronicle testing extension | Completion of the application’s entire observer/reactor flow | | Projection/reducer state from history | Chronicle read-model scenario | Production sink configuration and transport | | Actual host, provider, or asynchronous application flow | Hosted/provider-backed integration spec | Every business edge case unless you explicitly cover it | **Our recommendation: combine boundaries, rather than choose one for every test.** Cover decision branches with fast direct specs, add focused scenario specs for the important Arc contracts, and use a smaller set of integration tests to prove real composition. Keep the action in `Because()`, give every `should_...` assertion a concrete outcome, and wait for facts rather than sleeping. Start with [the decision-and-pipeline lesson](/arc/backend/csharp/testing/command-decisions/). It tests the same strongly typed command both ways. For inline side effects, [test command operations](/arc/backend/csharp/testing/command-operations/): inspect declarations directly, then let `CommandScenario` run actual execution and compensation with substitutes. For event-sourced applications, [the Chronicle lesson](/arc/backend/csharp/testing/event-sourced-commands/) shows why a correct returned event is not enough: the appended identity must be correct too. This separation is one of Arc’s useful design properties. `Provide()` can acquire data while `Handle()` expresses the decision, without an application-specific handler abstraction or a test-only execution path. A service-backed `Handle()` is also supported; it is testable but is not a pure function merely because it lives on a command. For pipeline specs, start with `Cratis.Arc.Testing`. It has no Chronicle dependency. Your handlers still need their application services; register test implementations or real dependencies as appropriate. The scenario does not automatically replace external databases or services your code uses. ## Packages | Package | Purpose | | --- | --- | | `Cratis.Specifications.XUnit` | BDD-style `Specification` base class and assertions on top of xUnit | | `Cratis.Arc.Testing` | Command and query scenarios without event sourcing | | `Cratis.Arc.Chronicle.Testing` | Optional in-process Chronicle extension for event-sourced commands | | `Cratis.Testing` | Optional convenience meta-package for projects using both Arc and Chronicle | []()[]() ## Try a standalone command spec In an existing xUnit test project, add the focused packages. Use versions compatible with your application’s Arc packages: ```bash dotnet add package Cratis.Specifications.XUnit dotnet add package Cratis.Arc.Testing ``` []() The following is a complete test file. It uses [Cratis Specifications](/testing-with-cratis/): with the default xUnit runner, each `[Fact]` gets a fresh spec instance, `Because()` performs the action before that fact, and `Destroy()` cleans up afterward. No database or Chronicle setup is needed. ```csharp using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using Xunit; namespace CommandSpecs; [Command] public record NormalizeName(string Name) { public string Handle() => Name.Trim(); } public class when_normalizing_a_name : Specification { readonly CommandScenario _scenario = new(); CommandResult _result = default!; async Task Because() => _result = await _scenario.Execute(new NormalizeName(" Ada ")); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] void should_return_the_normalized_name() => ((CommandResult)_result).Response.ShouldEqual("Ada"); void Destroy() => _scenario.Dispose(); } ``` Run `dotnet test`. Both facts should pass: the result succeeds and its response is `"Ada"`. The handler returns an ordinary string, not an event. This checkpoint tests the pipeline and handler, not routing, HTTP serialization, or middleware. []() ## Add validation and dependencies The scenario builds its service provider lazily on the first `Execute` or `Validate` call. Register application dependencies through `Services` before that point, usually in `Establish()`. Use `Validate` to run the pipeline filters without calling `Provide()` or `Handle()`. Continue with [command scenarios](/arc/backend/csharp/testing/command-scenario/) for validation, authorization, assertion contracts, and disposal. []() ## Optional: Test event-sourced commands Only commands using the Chronicle integration need `Cratis.Arc.Chronicle.Testing`. When its extender is discovered, the same scenario gains `Given`, `EventScenario`, `EventLog`, `EventSequence`, and `AppendedEvents`. You can seed state and inspect appended events without an external Chronicle server. See the [optional Chronicle testing extension](/arc/backend/csharp/testing/chronicle/) for setup, the distinction between event-log and read-model seeding, and event assertions. Keep the standalone package when you do not use event sourcing. The broader [Testing with Cratis guide](/testing-with-cratis/) connects Arc scenarios to the other product-specific test helpers. # Chronicle testing (optional) > Extend Arc command scenarios with an in-process Chronicle event log and explicitly seeded read model state. This page applies **only to commands using the optional Chronicle integration**. Standalone Arc commands use [Cratis.Arc.Testing](/arc/backend/csharp/testing/command-scenario/) and do not need an event store or this extension. When `Cratis.Arc.Chronicle.Testing` (or the `Cratis.Testing` meta-package) is referenced, `CommandScenario` is automatically extended with an in-memory event scenario. No separate class or base type is needed. The extension is wired via `ChronicleCommandScenarioExtender`, which implements `ICommandScenarioExtender` and is discovered automatically by `CommandScenario` at construction time using the Cratis type discovery system. In a Cratis Specification, that gives you the event-sourced test shape directly: seed prior facts with `_scenario.EventScenario.Given` in `Establish()`, execute the command once in `Because()`, then assert the `CommandResult` and the captured events from `[Fact]` methods. ## Package ```xml ``` Or via the meta-package: ```xml ``` ## Basic usage The C# examples below are **illustrative spec fragments**, not standalone runnable checkpoints. They assume your application’s commands, event types, projections/reducers, and named constraints already exist and are discovered. Seeding an event does not invent a uniqueness rule. Use the [standalone testing checkpoint](/arc/backend/csharp/testing/) to learn the scenario lifecycle first. Import `Cratis.Arc.Testing.Commands` for Arc assertions, `Cratis.Arc.Chronicle.Testing.Commands` for the C# 14 extension properties and capture assertions, `Cratis.Chronicle.Testing.EventSequences` for event-log assertions, and your normal `Cratis.Specifications` / `Xunit` namespaces. Event IDs and sequence numbers live in `Cratis.Chronicle.Events` and `Cratis.Chronicle.EventSequences`. Dispose each scenario in `Destroy()` as shown in [command scenarios](/arc/backend/csharp/testing/command-scenario/#disposal). Use the same `CommandScenario` class as for non-Chronicle commands. When the Chronicle testing package is present, these extension properties are available directly on the scenario: | Property | Type | Purpose | | --- | --- | --- | | `Given` | `CommandScenarioChronicleGivenBuilder` | Seed the read model state a command observes — `Given.ForEventSource(id).Events(...)` or `.ReadModel(...)` | | `EventScenario` | `EventScenario` | The full event scenario — use `EventScenario.Given` to seed the event log | | `EventLog` | `IEventLog` | The in-memory event log — use for appending and assertions | | `EventSequence` | `IEventSequence` | The event scenario’s sequence — use with Chronicle’s assertion helpers | | `AppendedEvents` | `IReadOnlyList` | Appends captured since scenario construction, including event-log seeding | ```csharp public class when_registering_author : Specification { readonly EventSourceId _authorId = EventSourceId.New(); readonly CommandScenario _scenario = new(); CommandResult _result = default!; async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_authorId, "Jane Austen")); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] Task should_have_appended_registered_event() => _scenario.EventLog.ShouldHaveAppendedEvent(_authorId); } ``` The spec executes the command once and then asserts both the Arc result and the Chronicle fact that was recorded. ## Seeding pre-existing events with `Given` Use `_scenario.EventScenario.Given` to append events to the in-memory event log *before* the command runs. Call `ForEventSource` with the event source identifier, then pass the pre-existing events to `Events`: ```csharp public class when_registering_author_with_same_name : Specification { readonly EventSourceId _authorId = EventSourceId.New(); readonly CommandScenario _scenario = new(); CommandResult _result = default!; Task Establish() => _scenario.EventScenario.Given .ForEventSource(_authorId) .Events(new AuthorRegistered("Jane Austen")); async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_authorId, "Jane Austen")); [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); [Fact] void should_report_the_existing_author() => _result.ShouldHaveValidationErrorFor("Author is already registered"); [Fact] Task should_not_have_appended_a_second_event() => _scenario.EventLog.ShouldHaveTailSequenceNumber(EventSequenceNumber.First); } ``` Seed events before calling `Execute` so they are present when the command handler runs. This example assumes the application’s validator rejects a repeated registration with the message `"Author is already registered"`. The message assertion pins that intended rejection: a missing dependency alone must not make this spec pass. Event-log seeding alone does not seed injected read models; use the separate `Given` builder below if the validator reads projected state. ## EventLog assertion helpers Chronicle’s `EventSequenceShouldExtensions` extend `IEventSequence`. Call them on `_scenario.EventLog` or `_scenario.EventSequence`. They read the log, including pre-existing events, rather than only the latest command’s output. | Call shape (all return `Task`) | Meaning | | --- | --- | | `ShouldHaveAppendedEvent()` | An event of that type exists anywhere in the sequence | | `ShouldHaveAppendedEvent(eventSourceId)` | An event of that type exists for that source | | `ShouldHaveAppendedEvent(eventSourceId, predicate)` | A matching event for that source satisfies `Func` | | `ShouldHaveAppendedEvent(sequenceNumber)` | That exact global sequence position contains the type | | `ShouldHaveAppendedEvent(sequenceNumber, eventSourceId, predicate)` | That exact position also belongs to the source and satisfies the predicate | | `ShouldHaveTailSequenceNumber(expected)` | The log’s tail equals the expected `EventSequenceNumber` | Content overloads also accept `Action` assertions. An action validates the first matching event; a predicate searches for a satisfying event. For all overloads, see the [Chronicle event assertions reference](/chronicle/testing/events/assertions/). ## Competing appends `AppendConcurrently(source, events)` works with or without decision mode. Queue competitors before the first `Execute` or `Validate`; the harness appends them after a successful handler and response processing, before the command transaction commits. The overload taking an `EventRoute` preserves the competitor’s routing. Nested commands do not drain the outer command’s queue. For `Off` commands without protected dependencies, the scenario keeps its existing `EventScenario`, read-model seeds and pins. Competing appends do not add a guard: use an explicit concurrency scope or a stream decision to test that kind of conflict. Legacy read-model seeds remain separate from the command log; seed `EventScenario.Given` for an explicit log-boundary test. Competitors are excluded from `AppendedEvents`; ordinary cumulative capture still includes attempted command appends, including failures. Inspect successful results or the log to prove that an owner event committed. Custom execution scope registrations are refused when enabling competing appends because the harness cannot safely order them around the transaction owner. ## Protected decision scenarios Create a plain `new CommandScenario()`. Commands declaring read protection `On` or `Measure`, `[ProtectedDecision]`, decision or stream dependencies, per-stream read models, or guarded generated parameters automatically get one in-process event log **before seeding**. The scenario uses the command’s runtime-resolved assembly/type declarations, including ``. The pipeline still applies the host’s runtime cap on each execution; selecting the log does not itself add a guard to an `Off` or `Measure` command. Scenario decision mode applies the host’s validator boundary: under `On`, validators with read-model parameters run as in production, including when only the validator reads state. Outside `On`, a `[ProtectedDecision]` command runs only [parameterless validators](/arc/backend/csharp/chronicle/consistency/validators/#commands-marked-protecteddecision). `Off` commands without protected dependencies keep the ordinary `EventScenario`, separate read-model seeds and pinned `.ReadModel(...)` state shown above. See [Test the race](/arc/backend/csharp/chronicle/consistency/scenarios/testing/) for a complete example: ```csharp await using var scenario = new CommandScenario(); scenario.Given.ForEventSource(authorId).Events(new AuthorRegistered("Jane Austen")); scenario.AppendConcurrently(authorId, new AuthorRegistered("Another writer")); var result = await scenario.Execute(new RegisterAuthor(authorId, "New name")); result.ShouldHaveValidationErrorBecauseOf(ValidationResultReason.ConcurrencyViolation); ``` This is an illustrative fragment: `RegisterAuthor` is marked `[ProtectedDecision]` (`Cratis.Arc.Chronicle.ReadModels`); a direct event-source-keyed admitted projection, event type registration and a handler using `DecisionRead` must exist. Decision mode uses Chronicle’s `EventStoreForTesting`: seeded events, decision folds, competing events and owner commits share **one real in-process event log**. `AppendConcurrently` queues an append outside the command’s transaction. After handler decision reads and response processing, the competitor is appended before the command owner commits. A matching event conflicts even if the command appends to a different source, and a successful command returning no events still validates its decision. Assert the result’s `concurrencyViolation` and that no **command** event committed; a competitor event is expected to remain in the log. `scenario.AppendedEvents` and scenario-level `ShouldHaveAppendedEvent` count successful **command** appends in decision mode, not seeded or competing events. `scenario.EventLog` and `scenario.EventSequence` point at the shared log, so log assertions see *all* stored events. There is no `EventScenario` facade over that store. In decision mode `scenario.EventScenario` throws rather than returning a misleading second log; use `Given.ForEventSource(...).Events(...)` to seed. The scenario selects the store during construction, so there is no late switch that could discard seeds. Ordinary scenarios keep their existing `EventScenario` and cumulative append behavior. No public API accepts invented sequence numbers or handcrafted decision tokens. This in-process harness does not stand in for a server, storage backend, authorization middleware or Chronicle’s consistency exclusions. Revise/redact, generation migration, projection-definition changes, external state, and writes that bypass the kernel’s serialized append path are outside the guard. A guard detects every matching event appended through the kernel, whoever wrote it; an unguarded writer’s own decision (a plain read, `[Unprotected]` or a direct append) is not protected. A direct SDK `Rollback()` or `Commit()` of a protected command’s unit of work, including from an aggregate, is refused with `ProtectedUnitOfWorkRequiresOwner`; Arc completes the unit as its owner. For production admission, opt-out and advisory validation behavior see [decision reads in commands](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/#decision-reads-for-event-dependent-commands). ### Reads that do not guard the decision A protected command can also read state it does not guard: an injected read model, `IReadModels.GetInstanceById`, or a `DecisionRead` in an `[Unprotected]` command. In decision mode these resolve the way production resolves them from a caught-up read model store: * Events seeded with `Given.ForEventSource(...).Events(...)` (or appended to `scenario.EventLog` before `Execute`) are projected through the read model’s own projection or reducer, from the same log the decision reads fold. Seed once; both kinds of read see it. * A read model exists only once an event its projection or reducer handles has been seeded for the event source: a nullable read model stays `null` when the source only has unrelated events, as in production. * A read is resolved from the events seeded for the one event source it is asked for, and the key is not matched against the result. A read model keyed by an event property is still materialized, under that property’s value, and is returned for the event source whose events produced it, whether or not the value equals the event source id; events seeded for another source never contribute, so a read for a source with only unrelated events is `null`. If one source’s events produce several instances (several key values), the command fails. Parts fed by the events of other sources, such as children, come out empty. Pin the read model with `.ReadModel(...)` to supply the complete value. * A pinned `.ReadModel(...)` instance serves these reads and takes precedence over seeded events for that read model and event source, whether the events were seeded before or after the pin. * A competing event queued with `AppendConcurrently` arrives after the reads, so it neither shows up in them nor conflicts with them: only decision reads are guarded. A protected decision read always folds the log and never sees a pinned instance. Pinning a read model that the command takes as a `DecisionRead` parameter throws `PinnedReadModelCannotProvideDecisionToken`; a read made through `IDecisionReads.Get(key)` is not visible up front, so a pin is never refused for it and never reaches it: seed events for it. ### Check that Chronicle admits the decision reads Chronicle admits flat, event-source-keyed projections and unfiltered reducers with no constructor dependencies except `ILogger`; self-reducing models are exempt from that constructor restriction. A protected read whose model has children or nested objects, a join, unsupported reducer dependencies or filters, more than one projection, or routing by an event property compiles, but the command fails each time it executes. `DecisionReadAdmissions` runs Chronicle’s own admission rules over the artifacts discovered for the test run, without executing anything, so one spec can guard every protected command: ```csharp public class when_checking_decision_read_admission { [Fact] public void should_admit_every_protected_decision_read() => DecisionReadAdmissions.ShouldAdmitDecisionReadsIn(typeof(RegisterAuthor).Assembly); } ``` `ShouldAdmitDecisionReadsIn` throws `DecisionReadsAreRefused`, listing every command, read model and `DecisionReadRefusalReason`. `ShouldAdmitDecisionReadsOf(params Type[])` checks selected commands, and `FindRefused` returns the list instead of throwing. The helper checks `DecisionRead` parameters (and `IEnumerable>`) of `Handle` and `Provide` on `[ProtectedDecision]` commands and commands whose declared read protection is `On`. For `On`, it also checks plain Chronicle-backed model parameters and discoverable validator constructor parameters, except reads marked advisory on the parameter or model. Command and assembly declarations are honored; the runtime cap is ignored. Inherited methods are included, and abstract commands are skipped when scanning an assembly. `IDecisionReads.Get(key)` calls inside a method body are not checked: give those models their own `Admit()` check or a scenario that runs the command. Projections compiled into the test assembly take part in discovery, so a spec-local projection for a production read model can make that model ambiguous here. ### Assert stream completion For a command returning `CompleteStream`, `scenario.ShouldHaveCompletedStream(type, id)` accepts a successful completion with or without an event source scope. To assert the exact owner, pass a `ClosedStreamScope` instead; see [completion assertions](/arc/backend/csharp/chronicle/commands/completing-streams/#assert-completion-in-a-command-scenario). Other sources using the same stream type and id remain open after a scoped close. A command without an event source id still closes that pair for every source. Explicit stream-scoped `IDecisionReads.Get` calls also work in decision mode. Seed events with `OnRoute(...)` and queue competitors on that same route to check the stream guard. The static admission helper uses stream admission for plain per-stream model parameters under `On`. Use `AdmitForStream()` for explicit stream folds inside method bodies; the helper cannot inspect those calls. ## Transactional commands in tests The harness runs commands with the same [transactional scope](/arc/backend/csharp/commands/transactional-commands/) as production: the events a command returns — and appends through `eventLog.Transactional` — commit atomically when it succeeds and roll back when it fails, including when a unique constraint rejects the commit. Immediate appends through `IEventLog`/`IEventStore.EventLog` land right away and are final, but a failed one fails the command. That gives specs two natural assertions: the intended rejection and the absence of committed events. The following fragment assumes the application’s registered unique-name constraint covers `AuthorRegistered.Name` across authors. `AuthorConstraintNames.UniqueName` is the application’s string constant for that constraint’s registered name, not an Arc-provided name. Use your actual constraint-name constant; seeding an event alone does not register a constraint. ```csharp public class when_registering_author_with_taken_name : Specification { readonly EventSourceId _existing = EventSourceId.New(); readonly EventSourceId _author = EventSourceId.New(); readonly CommandScenario _scenario = new(); CommandResult _result = default!; Task Establish() => _scenario.EventScenario.Given .ForEventSource(_existing) .Events(new AuthorRegistered("Jane Austen")); async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_author, "Jane Austen")); [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); [Fact] void should_be_rejected_by_the_uniqueness_constraint() => _result.ShouldHaveConstraintViolationFor(AuthorConstraintNames.UniqueName); [Fact] async Task should_append_nothing_for_the_rejected_author() => (await _scenario.EventLog.HasEventsFor(_author)).ShouldBeFalse(); } ``` The named assertion checks both `ConstraintViolation` and the exact constraint name, so a dependency-resolution failure or a different constraint cannot satisfy it. Keep the no-events assertion as well: it verifies the separate storage contract. Two things to be aware of: * **When events show up in `AppendedEvents` depends on the style.** Immediate appends surface as they happen; the command’s enrolled events — returned events and `Transactional` appends — surface as one batch when the command’s transaction commits. * **Immediate appends are final.** A handler that appends through the plain `IEventLog.Append` (or `IEventStore.EventLog`) writes immediately — a successful append remains in the log even when the command fails afterwards, and a spec can assert exactly that. ## Commands for occurrences `IOccurrenceCommandExecutor` needs a kernel that advertises `effect-receipts`. The in-process kernel from `Cratis.Chronicle.Testing` has a capability switch (`KernelCapabilitiesForTesting.Current()`, `Old()`, `Unknown()`) and stores receipts, so a spec can assert that a redelivered effect key returns `AlreadyRecorded`, that a business refusal is recorded as `Refused`, that a handler exception leaves no receipt, and that the executor throws `EffectReceiptsNotSupported` against an old or unknown kernel. See [Commands for occurrences](/arc/backend/csharp/chronicle/commands/occurrence-commands/) for the classification and for the commit styles the executor refuses. ## Testing commands that use EventForEventSourceId When a command handler returns `EventForEventSourceId` or `IEnumerable`, it can target sources other than the command’s own source. **Both** event-log assertions and the scenario-level helpers below support filtering by event source ID. Choose log assertions to verify readable stored events; choose scenario helpers to inspect appends captured through the client-side `AppendOperations` observable. The capture subscription starts when the scenario is constructed, not at each `Execute`. `AppendedEvents` accumulates event-log seeding and subsequent executions. Use a fresh scenario, a source unique to the command, or an explicit baseline when distinguishing new appends from setup. A matching seeded event must not be mistaken for evidence that the command appended it. | Method | Asserts that… | | --- | --- | | `ShouldHaveAppendedEvent(eventSourceId)` | At least one event of type `TEvent` was appended for the given `EventSourceId` | | `ShouldHaveAppendedEvent(eventSourceId, predicate)` | Same, and the event also satisfies the predicate | | `ShouldHaveTailSequenceNumber(expected)` | The highest sequence number among all captured events equals `expected` | ### Example: Single cross-source event ```csharp using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Chronicle.Events; public class when_migrating_customer_to_new_id : Specification { readonly CommandScenario _scenario = new(); readonly EventSourceId _oldId = EventSourceId.New(); readonly EventSourceId _newId = EventSourceId.New(); CommandResult _result = default!; async Task Because() => _result = await _scenario.Execute(new MigrateCustomerToNewId(_oldId, _newId)); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] Task should_have_appended_migrated_event_for_new_id() => _scenario.ShouldHaveAppendedEvent(_newId); [Fact] Task should_reference_old_id_in_event() => _scenario.ShouldHaveAppendedEvent( _newId, e => e.OldCustomerId == _oldId); } ``` ### Example: Multiple cross-source events (fund transfer) ```csharp using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Chronicle.Events; public class when_transferring_funds : Specification { readonly CommandScenario _scenario = new(); readonly EventSourceId _fromAccount = EventSourceId.New(); readonly EventSourceId _toAccount = EventSourceId.New(); CommandResult _result = default!; async Task Because() => _result = await _scenario.Execute(new TransferFunds(_fromAccount, _toAccount, 250m)); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] Task should_have_debited_from_account() => _scenario.ShouldHaveAppendedEvent(_fromAccount); [Fact] Task should_have_credited_to_account() => _scenario.ShouldHaveAppendedEvent(_toAccount); [Fact] Task should_have_debited_correct_amount() => _scenario.ShouldHaveAppendedEvent(_fromAccount, e => e.Amount == 250m); [Fact] Task should_have_appended_two_events() => _scenario.ShouldHaveTailSequenceNumber(1ul); } ``` > **Sequence numbering applies here too**: `ShouldHaveTailSequenceNumber` checks the highest sequence number across captured appends and fails if none were captured. A tail of `1` means two total events only for a fresh, contiguous sequence starting at `0`. It is not a general count of events from the last command. The `AppendedEvents` extension property gives you the raw list if you need to write custom assertions: ```csharp [Fact] void should_have_exactly_two_events() => _scenario.AppendedEvents.Count.ShouldEqual(2); ``` ## Testing commands that take read model dependencies A command handler, `Provide` method, or `CommandValidator` can take a read model as a parameter — Arc resolves it for the command’s event source id exactly as it does at runtime (`IProjectionFor`, `IReducerFor`, and model-bound projections). See [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) for the production-side pattern. To test such a command you need to control what that read model contains, and the awkward way is to hand-mock `IReadModels`. `_scenario.Given.ForEventSource(id)` does it for you, two ways: seed the **events** the read model is built from, or pin a materialized **instance** directly. ### Seeding read model state from events State the events that happened for the event source. Any read model a command injects for that source is materialized from those events through its own reducer or projection — you never name the read model type here, just as you never do in production: ```csharp public class when_withdrawing_with_sufficient_funds : Specification { readonly EventSourceId _accountId = EventSourceId.New(); readonly CommandScenario _scenario = new(); CommandResult _result = default!; void Establish() => _scenario.Given .ForEventSource(_accountId) .Events(new MoneyDeposited(100m), new MoneyDeposited(50m)); async Task Because() => _result = await _scenario.Execute(new Withdraw(_accountId, 120m)); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); } ``` Events are the facts; read models are derived from them. One `Events(...)` call feeds *every* read model built from those events: if the command injects both an `AccountBalance` and an `AccountStatement`, both are materialized from the same events — no read model type appears in the test. This is distinct from `_scenario.EventScenario.Given` above: that seeds the event **log** (prior facts the handler may read or append against); this seeds the **read model state** the command observes through its injected parameters. [Observable read-model queries](/arc/backend/csharp/testing/query-scenario/#observe-chronicle-read-models) use a private log that combines read-model seeds and successful appends from the command log, without changing the command log. ### Pinning a read model instance When you would rather assert against a known value than express the events behind it, pin the instance directly. The read model type is inferred from the value: ```csharp void Establish() => _scenario.Given .ForEventSource(_accountId) .ReadModel(new AccountBalance(150m)); ``` Pinned instances take precedence over seeded events for the same read model type and source. A model seeded for one source is not visible to another: resolving an unseeded source returns `null`. A registered nullable dependency can receive that null; a required dependency instead rejects the command as unavailable. See [the dependency-unavailable assertion distinction](/arc/backend/csharp/testing/command-scenario/#dependency-unavailable-is-not-a-business-rule-rejection). **Current boundary:** this builder controls injected read model resolution. Appending through `EventLog` or executing a command does not automatically add those new events to this separate seed collection. If a test needs both prior log facts and injected state, arrange both explicitly. Observable queries combine both histories in a private log, so seeding the same facts in both places processes them twice for observation. Pins require a readable key. [Observable queries support children, joins, and reducers in default mode](/arc/backend/csharp/testing/query-scenario/#children-joins-and-shared-command-scenarios). Use Chronicle’s read-model scenarios or hosted integration specs for projection behavior beyond the supported scenario boundaries. ## Multiple events For exact-position assertions, account for seeded events. This example assumes `CompleteOrder` appends one `OrderCompleted` after the seeded `OrderPlaced`, giving two total events: ```csharp public class when_completing_order : Specification { readonly CommandScenario _scenario = new(); readonly EventSourceId _orderId = EventSourceId.New(); CommandResult _result = default!; Task Establish() => _scenario.EventScenario.Given .ForEventSource(_orderId) .Events(new OrderPlaced("item-1", 3)); async Task Because() => _result = await _scenario.Execute(new CompleteOrder(_orderId)); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] Task should_have_two_events_including_the_seed() => _scenario.EventLog.ShouldHaveTailSequenceNumber(new EventSequenceNumber(1)); [Fact] Task should_have_appended_completed_event() => _scenario.EventLog.ShouldHaveAppendedEvent(new EventSequenceNumber(1)); } ``` > **Sequence numbering**: Sequence numbers are zero-based. `EventSequenceNumber.First` is `0`. The second event is `new EventSequenceNumber(1)`, the third `new EventSequenceNumber(2)`, and so on. `ShouldHaveTailSequenceNumber` reports the number of the *last* appended event — so two total events means a tail of `1`. ## What the extension provides When `Cratis.Arc.Chronicle.Testing` is referenced, `ChronicleCommandScenarioExtender` registers the following services automatically: * `IEventTypes` → discovered from the assemblies loaded in the test process (same convention used in production) * `IEventLog` → backed by the real in-process Chronicle kernel (no server required) * `IEventSequence` → the event scenario’s in-process sequence * `IReadModels` → resolves a command’s injected read models from the state seeded with the `Given` builder — by projecting seeded events on demand, or from a pinned instance — enabling direct read model dependencies in handlers, validators, and `Provide` methods It also populates the scenario context, exposed through C# 14 extension properties: | Property | Type | Purpose | | --- | --- | --- | | `Given` | `CommandScenarioChronicleGivenBuilder` | The given builder for seeding read model state — `ForEventSource(id).Events(...)` or `.ReadModel(...)` | | `EventScenario` | `EventScenario` | The full scenario, including the `Given` builder for seeding the event log | | `EventLog` | `IEventLog` | Shortcut to `EventScenario.EventLog` for Chronicle’s own assertion helpers | | `EventSequence` | `IEventSequence` | Shortcut to `EventScenario.EventSequence` for Chronicle’s assertion helpers | | `AppendedEvents` | `IReadOnlyList` | Accumulated captured appends, including event-log setup, used by scenario-level `ShouldHaveAppendedEvent` and `ShouldHaveTailSequenceNumber` | # Test a command's decision and its pipeline > Learn when to call Handle directly and when to use CommandScenario, using the same standalone Arc command. A shipping quote has two different things to prove: **the calculation is right**, and **Arc validates the input and supplies the rate before calculating it**. You do not need a server to prove either one—but they are different tests. In this lesson, write a fast direct spec for the decision, then a `CommandScenario` spec for the pipeline. Neither requires Chronicle or a database. ## Create the projects Use the latest .NET SDK and current Cratis packages. In a new working directory: ```bash dotnet new classlib -n Shipping dotnet add Shipping package Cratis.Arc.Core dotnet new xunit -n Shipping.Specs dotnet add Shipping.Specs reference Shipping/Shipping.csproj dotnet add Shipping.Specs package Cratis.Specifications.XUnit dotnet add Shipping.Specs package Cratis.Arc.Testing dotnet add Shipping.Specs package NSubstitute ``` Remove the template’s `Class1.cs` and `UnitTest1.cs` if present. Keep application types in `Shipping` and specs in `Shipping.Specs`. These are dedicated test projects; their tests need no `#if DEBUG` wrapper. The commands below run them explicitly by project. ## Keep acquiring data separate from deciding Create `Shipping/QuoteShipping.cs`: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Concepts; using FluentValidation; namespace Shipping; public record ParcelWeight(decimal Value) : ConceptAs(Value) { public static readonly ParcelWeight NotSet = new(0m); } public record RatePerKilogram(decimal Value) : ConceptAs(Value); public record ShippingCost(decimal Value) : ConceptAs(Value); public interface IRateCard { Task GetCurrentRate(); } [Command] public record QuoteShipping(ParcelWeight Weight) { public Task Provide(IRateCard rates) => rates.GetCurrentRate(); public ShippingCost Handle(RatePerKilogram rate) => new(Weight.Value * rate.Value); } public class ParcelWeightValidator : ConceptValidator { public ParcelWeightValidator() => RuleFor(weight => weight.Value) .GreaterThan(0m) .WithMessage("Parcel weight must be positive."); } ``` `ParcelWeight` is measured in kilograms; the rate and cost use the same configured currency. They have separate types so a weight, rate, and cost cannot accidentally trade places in C#. Shared concepts normally get their own files in the feature; they are together here to make the lesson easy to follow. `Provide()` acquires the rate. `Handle()` only multiplies the supplied values: the same weight and rate produce the same cost, with no I/O, clock, randomness, or mutable external state. That makes this handler a **pure function**. Arc does not require every handler to be pure; this separation is useful when acquiring data would otherwise obscure a decision. `ParcelWeightValidator` expresses an invariant of the value, not just this command. Arc discovers it and applies it when validating commands carrying that concept. Calling `Handle()` yourself does not run validation or `Provide()`. ## Specify the decision directly Create `Shipping.Specs/for_QuoteShipping/when_quoting_shipping_directly.cs`: ```csharp using Cratis.Specifications; using Shipping; using Xunit; namespace Shipping.Specs; public class when_quoting_shipping_directly : Specification { ShippingCost _cost = null!; void Because() => _cost = new QuoteShipping(new ParcelWeight(2.5m)) .Handle(new RatePerKilogram(4m)); [Fact] void should_quote_ten_currency_units() => _cost.ShouldEqual(new ShippingCost(10m)); } ``` Run: ```bash dotnet test Shipping.Specs --filter FullyQualifiedName~when_quoting_shipping_directly ``` The spec passes when the decision returns `ShippingCost(10)`. There is no service container, rate-card substitute, HTTP server, or framework harness in this test. Add decision cases here when the calculation gains thresholds, rounding rules, or different tariffs. Cratis Specifications gives the example a readable shape: `Because()` performs the action, and `should_...` names the expected behavior. Use `Establish()` for setup when a case needs it and `Destroy()` for owned resources. ## Prove that Arc connects the pieces Create `Shipping.Specs/for_QuoteShipping/when_quoting_shipping_through_arc.cs`: ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Shipping; using Xunit; namespace Shipping.Specs; public class when_quoting_shipping_through_arc : Specification { readonly CommandScenario _scenario = new(); IRateCard _rates = null!; CommandResult _result = null!; void Establish() { _rates = Substitute.For(); _rates.GetCurrentRate().Returns(Task.FromResult(new RatePerKilogram(4m))); _scenario.Services.AddSingleton(_rates); } async Task Because() => _result = await _scenario.Execute(new QuoteShipping(new ParcelWeight(2.5m))); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] async Task should_acquire_the_rate_once() => await _rates.Received(1).GetCurrentRate(); [Fact] void should_return_the_calculated_cost() => ((CommandResult)_result).Response.ShouldEqual(new ShippingCost(10m)); void Destroy() => _scenario.Dispose(); } ``` Here the boundary matters: `Execute()` runs Arc’s filters, resolves `Provide()` and its service, supplies the rate to `Handle()`, and processes the response. Register the application dependency before the first execution; the scenario builds its service provider lazily. It discovers validators rather than requiring the test to register each one. The cast uses the handler’s actual response type. `CommandScenario.Execute()` returns the non-generic `CommandResult`; it does not have its own `Execute()` overload. An unsuccessful result need not contain a typed response. See [the scenario reference](/arc/backend/csharp/testing/command-scenario/#package-and-api). ## Prove rejected input does not reach the rate card Create `Shipping.Specs/for_QuoteShipping/when_quoting_an_unset_weight.cs`: ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Shipping; using Xunit; namespace Shipping.Specs; public class when_quoting_an_unset_weight : Specification { readonly CommandScenario _scenario = new(); IRateCard _rates = null!; CommandResult _result = null!; void Establish() { _rates = Substitute.For(); _scenario.Services.AddSingleton(_rates); } async Task Because() => _result = await _scenario.Execute(new QuoteShipping(ParcelWeight.NotSet)); [Fact] void should_reject_the_weight() => _result.ShouldHaveValidationErrorFor("Parcel weight must be positive."); [Fact] async Task should_not_acquire_a_rate() => await _rates.DidNotReceive().GetCurrentRate(); void Destroy() => _scenario.Dispose(); } ``` Run all the lesson’s specs: ```bash dotnet test Shipping.Specs ``` The six facts cover three different questions: the calculation, successful pipeline composition, and rejection before acquisition. Checking the specific message and the untouched collaborator prevents an unrelated failure from masquerading as the expected rejection. ## What you have proved—and what comes next You have fast tests for the decision and focused tests for Arc’s composition. You have **not** tested an HTTP route, authentication middleware, or a production rate-card implementation. Test those where they are introduced rather than adding infrastructure to every arithmetic case. For inline service side effects, prefer [command operations](/arc/backend/csharp/commands/operations/). `Handle()` can return the work it intends to perform, and Arc manages execution and optional compensation. [Test command operations](/arc/backend/csharp/testing/command-operations/) extends this lesson with direct declaration specs and real `CommandScenario` recovery tests. A handler that writes directly through a service remains supported; unit specs can use a substitute to inspect the call. Such a handler is testable, but it is not a pure function. The [service-backed command recipe](/arc/scenarios/test-a-command/) shows the pipeline-level counterpart. With Chronicle, a handler can instead return a domain event as its decision; a scenario must then prove how the integration appends it. Continue to [test an event-sourced command](/arc/backend/csharp/testing/event-sourced-commands/), or return to [choosing a test boundary](/arc/backend/csharp/testing/#choose-the-boundary-that-can-catch-the-bug). # Specify command operation failure cases > Test cancellation, execution-path validation rejection, missing compensation, and failed compensation through the real CommandScenario pipeline. **Goal:** add focused regression specs for recovery boundaries that a successful booking test cannot cover. These recipes extend [testing command operations](/arc/backend/csharp/testing/command-operations/) and reuse its `SeatBooking` application declarations and spec packages. Each case runs the real `CommandScenario` pipeline with a controlled provider. No command or operation implements a test-only error-handling path. ## Share the scenario setup Add this reusable specification context. The base `Establish()` registers the provider before a derived case configures its specific failure. `Destroy()` disposes the scenario. FailureCases/given/a_booking_scenario.cs ```csharp using System; using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using SeatBooking; namespace SeatBooking.Specs.FailureCases.given; public abstract class a_booking_scenario : Specification { protected readonly CommandScenario _scenario = new(); protected readonly ReservationId _first = new(Guid.Parse("757660b8-55ac-4c1a-89cb-8151c8fd3562")); protected readonly ReservationId _second = new(Guid.Parse("b52a8554-fab3-4050-af81-554d105d9b9d")); protected readonly ReservationId _third = new(Guid.Parse("0aeb4b2f-0515-44fe-8e79-7c8f304d2c65")); protected ISeatReservations _reservations = null!; protected CommandResult _result = null!; void Establish() { _reservations = Substitute.For(); _reservations.Reserve(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _reservations.Cancel(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _scenario.Services.AddSingleton(_reservations); } void Destroy() => _scenario.Dispose(); } ``` The failure types below give the fault-injection cases precise messages to assert: FailureCases/Failures.cs ```csharp using System; namespace SeatBooking.Specs.FailureCases; public class ForwardFailure() : Exception("Reserve failed."); public class CompensationFailure() : Exception("Cancel failed."); ``` ## Cancel during execution without canceling cleanup Cancel the forward token from inside the controlled provider call. No delay or real network race is needed: FailureCases/when_execution_is_canceled.cs ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Specifications; using NSubstitute; using SeatBooking; using SeatBooking.Specs.FailureCases.given; using Xunit; namespace SeatBooking.Specs.FailureCases; public class when_execution_is_canceled : a_booking_scenario { readonly CancellationTokenSource _cancellation = new(); void Establish() => _reservations.Reserve(_first, Arg.Any(), _cancellation.Token) .Returns(async _ => { await _cancellation.CancelAsync(); _cancellation.Token.ThrowIfCancellationRequested(); }); async Task Because() => _result = await _scenario.Execute( new BookSeats( [ new SeatReservation(_first, new SeatId("A-12")), new SeatReservation(_second, new SeatId("A-13")) ]), _cancellation.Token); [Fact] void should_keep_the_command_unsuccessful() => _result.IsSuccess.ShouldBeFalse(); [Fact] Task should_not_start_the_next_operation() => _reservations.DidNotReceive().Reserve(_second, Arg.Any(), Arg.Any()); [Fact] Task should_compensate_with_a_separate_uncanceled_token() => _reservations.Received(1).Cancel(_first, Arg.Is(token => !token.IsCancellationRequested && token != _cancellation.Token)); [Fact] void should_observe_the_compensation() => _result.Recovery!.CompensatedCount.ShouldEqual(1); void Destroy() => _cancellation.Dispose(); } ``` This proves that request cancellation does not immediately cancel compensation. It does not prove a hard cleanup deadline: compensators must cooperate with the separate recovery token. ## Reject input during Execute before provider work Define a validator for the existing `BookSeat` command and execute invalid input through the pipeline: FailureCases/when_input_is_rejected.cs ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using FluentValidation; using NSubstitute; using SeatBooking; using SeatBooking.Specs.FailureCases.given; using Xunit; namespace SeatBooking.Specs.FailureCases; public class BookSeatValidator : CommandValidator { public BookSeatValidator() => RuleFor(command => command.SeatId) .NotEqual(SeatId.NotSet) .WithMessage("Choose a seat."); } public class when_input_is_rejected : a_booking_scenario { async Task Because() => _result = await _scenario.Execute(new BookSeat(_first, SeatId.NotSet)); [Fact] void should_reject_the_specific_input() => _result.ShouldHaveValidationErrorFor("Choose a seat."); [Fact] void should_have_no_entered_operations() => _scenario.Operations.Count.ShouldEqual(0); [Fact] Task should_not_reserve() => _reservations.DidNotReceive().Reserve(Arg.Any(), Arg.Any(), Arg.Any()); [Fact] Task should_not_compensate_unstarted_work() => _reservations.DidNotReceive().Cancel(Arg.Any(), Arg.Any()); } ``` The specific validation message distinguishes the expected rejection from a missing provider or unrelated failure. Unlike a `Validate()` call alone, this exercises the rejection path of `Execute()`. ## Make unavailable compensation observable An operation can intentionally omit `Compensate()`. This test-only command combines such an operation with the ordinary `ReserveSeat` operation: FailureCases/when_compensation_is_unavailable.cs ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Specifications; using NSubstitute; using SeatBooking; using SeatBooking.Specs.FailureCases.given; using Xunit; namespace SeatBooking.Specs.FailureCases; public record ReserveWithoutCompensation(ReservationId ReservationId, SeatId SeatId) : ICommandOperation { public Task Execute(ISeatReservations reservations, CancellationToken cancellationToken) => reservations.Reserve(ReservationId, SeatId, cancellationToken); } [Command] public record TryBookingWithoutFullRecovery(ReservationId First, ReservationId Second) { public CommandOperations Handle() => [ new ReserveWithoutCompensation(First, new SeatId("A-12")), new ReserveSeat(Second, new SeatId("A-13")) ]; } public class when_compensation_is_unavailable : a_booking_scenario { void Establish() => _reservations.Reserve(_second, Arg.Any(), Arg.Any()) .Returns(Task.FromException(new ForwardFailure())); async Task Because() => _result = await _scenario.Execute(new TryBookingWithoutFullRecovery(_first, _second)); [Fact] void should_report_incomplete_recovery() => _result.Recovery!.Status.ShouldEqual(CommandRecoveryStatus.Incomplete); [Fact] void should_identify_the_missing_compensator() => _scenario.Operations[0].Compensation.ShouldEqual(CommandOperationCompensation.NotAvailable); [Fact] void should_report_one_uncompensated_invocation() => _result.Recovery!.UncompensatedCount.ShouldEqual(1); [Fact] Task should_still_compensate_the_other_operation() => _reservations.Received(1).Cancel(_second, Arg.Any()); } ``` The example deliberately supplies no reversal for the first operation. Its purpose is to prove reporting—not to recommend omitting a necessary cancellation in a production booking flow. ## Continue recovery after one compensator fails Fail the third execution and the second compensator. The first compensator must still run, and the original execution error must remain visible: FailureCases/when_a_compensator_fails.cs ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Specifications; using NSubstitute; using SeatBooking; using SeatBooking.Specs.FailureCases.given; using Xunit; namespace SeatBooking.Specs.FailureCases; public class when_a_compensator_fails : a_booking_scenario { void Establish() { _reservations.Reserve(_third, Arg.Any(), Arg.Any()) .Returns(Task.FromException(new ForwardFailure())); _reservations.Cancel(_second, Arg.Any()) .Returns(Task.FromException(new CompensationFailure())); } async Task Because() => _result = await _scenario.Execute(new BookSeats( [ new SeatReservation(_first, new SeatId("A-12")), new SeatReservation(_second, new SeatId("A-13")), new SeatReservation(_third, new SeatId("A-14")) ])); [Fact] void should_preserve_the_original_failure() => _result.ExceptionMessages.ShouldContain("Reserve failed."); [Fact] void should_keep_the_command_unsuccessful() => _result.IsSuccess.ShouldBeFalse(); [Fact] void should_record_the_failed_compensation() => _scenario.Operations[1].Compensation.ShouldEqual(CommandOperationCompensation.Failed); [Fact] void should_observe_two_successful_compensations() => _result.Recovery!.CompensatedCount.ShouldEqual(2); [Fact] void should_observe_one_failed_compensation() => _result.Recovery!.FailedCompensationCount.ShouldEqual(1); [Fact] void should_attempt_all_three_compensators_in_reverse_order() => Received.InOrder(() => { _ = _reservations.Cancel(_third, Arg.Any()); _ = _reservations.Cancel(_second, Arg.Any()); _ = _reservations.Cancel(_first, Arg.Any()); }); } ``` Run the failure cases: ```bash dotnet test SeatBooking.Specs --filter FullyQualifiedName~FailureCases ``` These specs deliberately inject faults into collaborators; application operations still contain only their forward work and optional business reversal. For supported commit profiles and uncertain outcomes, continue with the [operation execution reference](/arc/backend/csharp/commands/operations/reference/#commit-and-recovery). # Test command operations and compensation > Specify operation decisions directly, then run real execution and compensation through CommandScenario with controlled dependencies. A booking test needs to answer more than “did the command return a reservation?” You also need to know that Arc calls the provider, stops after a failure, and compensates the work it started. Those are different boundaries—and you can test each without a server. In this lesson, first inspect the command’s decision. Then exercise the real operation pipeline through `CommandScenario`, including a provider failure and reverse-order compensation. You do not write a rollback stack in the command or the spec. ## Set up the lesson Create an application library and a separate spec project: ```bash dotnet new classlib -n SeatBooking dotnet add SeatBooking package Cratis.Arc.Core dotnet new xunit -n SeatBooking.Specs dotnet add SeatBooking.Specs reference SeatBooking/SeatBooking.csproj dotnet add SeatBooking.Specs package Cratis.Arc.Testing dotnet add SeatBooking.Specs package Cratis.Specifications.XUnit dotnet add SeatBooking.Specs package NSubstitute ``` Use matching current Arc package versions. Remove the template’s `Class1.cs` and `UnitTest1.cs`, then add the `Reservation.cs`, `ReserveSeat.cs`, `BookSeat.cs`, and `BookSeats.cs` declarations from [implementing an operation](/arc/backend/csharp/commands/operations/implementing/) to `SeatBooking`. The lesson needs no Chronicle package. Its reservation service is an application interface; the specs supply an NSubstitute implementation. `CommandScenario` does not automatically replace real external dependencies, so do not register a production reservation client in these specs. ## Specify the decision without infrastructure Create this spec: for_BookSeat/when_deciding_to_book.cs ```csharp using System; using Cratis.Specifications; using SeatBooking; using Xunit; namespace SeatBooking.Specs; public class when_deciding_to_book : Specification { readonly ReservationId _reservationId = new(Guid.Parse("757660b8-55ac-4c1a-89cb-8151c8fd3562")); readonly SeatId _seatId = new("A-12"); (ReservationId Response, ReserveSeat Operation) _decision; void Because() => _decision = new BookSeat(_reservationId, _seatId).Handle(); [Fact] void should_return_the_reservation_identifier() => _decision.Response.ShouldEqual(_reservationId); [Fact] void should_declare_the_requested_reservation() => _decision.Operation.ShouldEqual(new ReserveSeat(_reservationId, _seatId)); } ``` Run it: ```bash dotnet test SeatBooking.Specs --filter FullyQualifiedName~when_deciding_to_book ``` Both facts pass without a service container or provider substitute. The record’s value equality makes the operation intent directly inspectable. Calling `Handle()` did not execute `ReserveSeat.Execute()` or `Compensate()`. Add decision branches here when you introduce conditional reservations or additional operations. Use fixed inputs rather than the clock or randomly generated data when those would obscure the decision being specified. ## Specify the provider adapter directly A direct operation spec verifies how its data maps to the provider request: for_ReserveSeat/when_reserving_directly.cs ```csharp using System; using System.Threading; using System.Threading.Tasks; using Cratis.Specifications; using NSubstitute; using SeatBooking; using Xunit; namespace SeatBooking.Specs; public class when_reserving_directly : Specification { readonly ReservationId _reservationId = new(Guid.Parse("757660b8-55ac-4c1a-89cb-8151c8fd3562")); readonly SeatId _seatId = new("A-12"); ISeatReservations _reservations = null!; void Establish() { _reservations = Substitute.For(); _reservations.Reserve(_reservationId, _seatId, CancellationToken.None) .Returns(Task.CompletedTask); } Task Because() => new ReserveSeat(_reservationId, _seatId) .Execute(_reservations, CancellationToken.None); [Fact] Task should_reserve_the_requested_seat() => _reservations.Received(1).Reserve(_reservationId, _seatId, CancellationToken.None); [Fact] Task should_not_cancel_the_reservation() => _reservations.DidNotReceive().Cancel(Arg.Any(), Arg.Any()); } ``` This tests your adapter, not Arc’s orchestration. A direct method invocation does not automatically compensate on failure. The next specs cross that boundary deliberately. ## Execute through CommandScenario Create a scenario spec using the same command and provider interface: for_BookSeat/when_booking_through_arc.cs ```csharp using System; using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using SeatBooking; using Xunit; namespace SeatBooking.Specs; public class when_booking_through_arc : Specification { readonly CommandScenario _scenario = new(); readonly ReservationId _reservationId = new(Guid.Parse("757660b8-55ac-4c1a-89cb-8151c8fd3562")); readonly SeatId _seatId = new("A-12"); ISeatReservations _reservations = null!; CommandResult _result = null!; void Establish() { _reservations = Substitute.For(); _reservations.Reserve(_reservationId, _seatId, CancellationToken.None) .Returns(Task.CompletedTask); _scenario.Services.AddSingleton(_reservations); } async Task Because() => _result = await _scenario.Execute(new BookSeat(_reservationId, _seatId)); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] Task should_call_the_provider() => _reservations.Received(1).Reserve(_reservationId, _seatId, CancellationToken.None); [Fact] void should_observe_one_started_operation() => _scenario.Operations.Count.ShouldEqual(1); [Fact] void should_observe_successful_execution() => _scenario.ShouldHaveExecutedOperation(); [Fact] void should_not_need_compensation() => _result.Recovery!.Status.ShouldEqual(CommandRecoveryStatus.NotNeeded); [Fact] void should_return_only_the_caller_response() => ((CommandResult)_result).Response.ShouldEqual(_reservationId); void Destroy() => _scenario.Dispose(); } ``` `CommandScenario` builds its provider lazily, so register substitutes before the first execution. It runs the actual command pipeline and operation invokers. The `Operations` snapshot reports entered invocations from the latest `Execute`; it is not a list of declarations that a recording stub pretended to execute. The cast uses the actual response type. An operation failure may produce a result without a usable typed response, so check success before consuming responses in application code. ## Prove compensation after a partial failure Now make the second reservation fail. The first two operations enter `Execute()`, but the third must never start. Arc should attempt cancellation of the second reservation first, because the provider may have accepted it before returning a failure. for_BookSeats/when_the_second_reservation_fails.cs ```csharp using System; using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using SeatBooking; using Xunit; namespace SeatBooking.Specs; public class ReservationProviderUnavailable() : Exception("Reservation provider unavailable."); public class when_the_second_reservation_fails : Specification { readonly CommandScenario _scenario = new(); readonly ReservationId _first = new(Guid.Parse("757660b8-55ac-4c1a-89cb-8151c8fd3562")); readonly ReservationId _second = new(Guid.Parse("b52a8554-fab3-4050-af81-554d105d9b9d")); readonly ReservationId _third = new(Guid.Parse("0aeb4b2f-0515-44fe-8e79-7c8f304d2c65")); ISeatReservations _reservations = null!; CommandResult _result = null!; void Establish() { _reservations = Substitute.For(); _reservations.Reserve(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _reservations.Reserve(_second, Arg.Any(), Arg.Any()) .Returns(Task.FromException(new ReservationProviderUnavailable())); _reservations.Cancel(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _scenario.Services.AddSingleton(_reservations); } async Task Because() => _result = await _scenario.Execute(new BookSeats( [ new SeatReservation(_first, new SeatId("A-12")), new SeatReservation(_second, new SeatId("A-13")), new SeatReservation(_third, new SeatId("A-14")) ])); [Fact] void should_preserve_the_command_failure() => _result.ShouldNotBeSuccessful(); [Fact] void should_preserve_the_original_error() => _result.ExceptionMessages.ShouldContain("Reservation provider unavailable."); [Fact] void should_record_only_the_two_entered_invocations() => _scenario.Operations.Count.ShouldEqual(2); [Fact] void should_reserve_the_first_two_seats_in_order() => Received.InOrder(() => { _ = _reservations.Reserve(_first, new SeatId("A-12"), Arg.Any()); _ = _reservations.Reserve(_second, new SeatId("A-13"), Arg.Any()); }); [Fact] void should_observe_the_first_execution_completing() => _scenario.Operations[0].ExecutionCompleted.ShouldBeTrue(); [Fact] void should_observe_the_second_execution_failing() => _scenario.Operations[1].ExecutionCompleted.ShouldBeFalse(); [Fact] Task should_not_start_the_third_reservation() => _reservations.DidNotReceive().Reserve(_third, Arg.Any(), Arg.Any()); [Fact] void should_compensate_in_reverse_order() => Received.InOrder(() => { _ = _reservations.Cancel(_second, Arg.Any()); _ = _reservations.Cancel(_first, Arg.Any()); }); [Fact] void should_observe_both_compensations() => _result.Recovery!.CompensatedCount.ShouldEqual(2); [Fact] void should_report_completed_recovery_without_success() => _result.Recovery!.Status.ShouldEqual(CommandRecoveryStatus.Completed); void Destroy() => _scenario.Dispose(); } ``` The original failure, exact provider calls, skipped operation, and recovery observations prove different things. A lone `ShouldNotBeSuccessful()` could pass because a dependency was missing before any operation ran; the additional assertions prevent that false positive. `Completed` means the required compensation callbacks returned. This substitute cannot prove that a production provider made cancellation durable or prevented a delayed reservation from appearing later. ## Add cancellation and rejection cases `CommandScenario.Execute(command, cancellationToken)` forwards the token into the real pipeline. Use it to test request cancellation after an operation starts. The compensator receives an independent cleanup token, not the canceled request token. For a cancellation spec, have a controlled collaborator cancel the forward token, then assert that compensation was invoked with a token that was not already canceled. Do not sleep or race a real network request to manufacture cancellation. The [execution reference](/arc/backend/csharp/commands/operations/reference/#cancellation-and-recovery-budget) explains the cooperative cleanup budget. Also call `Validate(command)` in a fresh scenario and assert that neither provider method was called. `Validate` does not invoke `Provide()`, `Handle()`, operations, compensation, or execution scopes. It is not a substitute for testing execution-time or commit-time rejection. When testing a specific validation rule, assert its message or reason in addition to proving the provider was untouched. See [precise scenario assertions](/arc/backend/csharp/testing/command-scenario/#commandresult-assertion-helpers). ## Run the lesson and choose the next boundary Run all the specs: ```bash dotnet test SeatBooking.Specs ``` You have specified the pure decision, the provider adapter, successful Arc composition, and reverse-order recovery after an execution failure. Your application code contains no error-handling stack. Continue with [operation failure cases](/arc/backend/csharp/testing/command-operation-failures/) for runnable cancellation, validation-rejection, missing-compensator, and failed-compensation specs. Backend `Recovery` and `OperationOutcomes` remain available to these tests; they are not serialized to frontend clients. At the provider boundary, also test a repeated request after a reservation was finalized: a failed new attempt must not cancel the earlier successful reservation. Verify conflicting key reuse, tenant/owner isolation, and delayed creation after cancellation. Substitutes cannot establish these external guarantees. For the optional Chronicle counterpart, [test recovery after a real constraint rejection](/arc/backend/csharp/testing/command-operations-with-chronicle/). That example combines an event and operation, seeds a conflict through the Chronicle scenario extension, and proves both non-commit and compensation. Already-committed or unknown outcomes must not trigger blind reversal. Do not infer actual server durability from a substitute’s `Task.CompletedTask`. Continue with the [operation reference](/arc/backend/csharp/commands/operations/reference/) for exact supported signatures, flat-command limits, and recovery observations, or the [testing overview](/arc/backend/csharp/testing/) to select provider-backed integration tests. # Test operation recovery after a Chronicle rejection > Combine a returned event and command operation, then prove a real in-process uniqueness rejection prevents the event commit and triggers compensation. **Goal:** prove that a returned operation can execute before Chronicle rejects the command’s event transaction, and that Arc then compensates the operation without committing the rejected event. This is the optional Chronicle counterpart to [testing standalone command operations](/arc/backend/csharp/testing/command-operations/). Keep it in a separate example project so adding Chronicle’s discovered scenario extender does not change the standalone lesson’s test environment. ## Add the Chronicle testing boundary ```bash dotnet new classlib -n Onboarding dotnet add Onboarding package Cratis.Arc.Chronicle dotnet new xunit -n Onboarding.Specs dotnet add Onboarding.Specs reference Onboarding/Onboarding.csproj dotnet add Onboarding.Specs package Cratis.Arc.Chronicle.Testing dotnet add Onboarding.Specs package Cratis.Arc dotnet add Onboarding.Specs package Cratis.Specifications.XUnit dotnet add Onboarding.Specs package NSubstitute ``` Use matching current Arc packages. Remove the template files. The example uses the real in-process Chronicle test event log and constraint evaluation, with a substitute only for the external reservation provider. It does not require a running Chronicle server. The explicit `Cratis.Arc` test dependency supplies the Arc host assembly used by Chronicle’s in-process kernel. This is a test-host dependency, not a requirement to expose an ASP.NET Core endpoint or to add Chronicle to standalone operation tests. ## Return an event and an operation Add these declarations to the `Onboarding` project. The event’s uniqueness constraint rejects a second onboarding with the same organization number. The reservation key identifies the attempted external work; it is separate from the Chronicle event-source identity. Onboarding.cs ```csharp using System; using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Chronicle.Events.Constraints; using Cratis.Concepts; namespace Onboarding; public record OrganizationNumber(string Value) : ConceptAs(Value) { public static readonly OrganizationNumber NotSet = new(string.Empty); } public record ReservationKey(Guid Value) : ConceptAs(Value) { public static readonly ReservationKey NotSet = new(Guid.Empty); } public interface IOnboardingReservations { Task Reserve(ReservationKey reservationKey, CancellationToken cancellationToken); Task Cancel(ReservationKey reservationKey, CancellationToken cancellationToken); } public sealed record ReserveOnboardingCapacity(ReservationKey ReservationKey) : ICommandOperation { public Task Execute(IOnboardingReservations reservations, CancellationToken cancellationToken) => reservations.Reserve(ReservationKey, cancellationToken); public Task Compensate(IOnboardingReservations reservations, CancellationToken cancellationToken) => reservations.Cancel(ReservationKey, cancellationToken); } [Command] public record StartOnboarding( EventSourceId EventSourceId, OrganizationNumber OrganizationNumber, ReservationKey ReservationKey) { public (OnboardingStarted Event, ReserveOnboardingCapacity Operation) Handle() => (new OnboardingStarted(OrganizationNumber), new ReserveOnboardingCapacity(ReservationKey)); } /// /// Records that a partner started onboarding under its organization number. /// [EventType] public record OnboardingStarted( [property: Unique("UniqueOrganizationNumber", "Organization number must be unique")] OrganizationNumber OrganizationNumber); ``` `Handle()` returns facts and work; it does not call the external provider. Arc enrolls the event, executes the operation, and completes the Chronicle transaction afterward. The event carries no event-source id; the command supplies it through Chronicle’s existing identity convention. The provider must honor the ownership and repeated-request rules described in [implementing an operation](/arc/backend/csharp/commands/operations/implementing/#define-the-operations-inputs-and-provider). The substitute below cannot prove those external guarantees. ## Seed the conflict, then execute the command Add this spec to `Onboarding.Specs`: when_the_organization_is_already_onboarding.cs ```csharp using System; using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Chronicle.Events; using Cratis.Specifications; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Onboarding; using Xunit; namespace Onboarding.Specs; public class when_the_organization_is_already_onboarding : Specification, IAsyncDisposable { CommandScenario _scenario = null!; readonly OrganizationNumber _organization = new("ORG-OPERATIONS"); readonly ReservationKey _reservationKey = new(Guid.Parse("96ce8c18-1e67-4e44-b5e7-4fb2b2cb3b97")); EventSourceId _newPartner = null!; IOnboardingReservations _reservations = null!; CommandResult _result = null!; async Task Establish() { _scenario = new CommandScenario(); _newPartner = EventSourceId.New(); _reservations = Substitute.For(); _reservations.Reserve(_reservationKey, Arg.Any()) .Returns(Task.CompletedTask); _reservations.Cancel(_reservationKey, Arg.Any()) .Returns(Task.CompletedTask); _scenario.Services.AddSingleton(_reservations); await _scenario.EventScenario.Given.ForEventSource(EventSourceId.New()) .Events(new OnboardingStarted(_organization)); } async Task Because() => _result = await _scenario.Execute(new StartOnboarding(_newPartner, _organization, _reservationKey)); [Fact] void should_report_the_actual_constraint() => _result.ShouldHaveConstraintViolationFor("UniqueOrganizationNumber"); [Fact] void should_observe_forward_execution() => _scenario.ShouldHaveExecutedOperation(); [Fact] void should_observe_compensation() => _scenario.ShouldHaveCompensatedOperation(); [Fact] void should_call_the_provider_in_order() => Received.InOrder(() => { _ = _reservations.Reserve(_reservationKey, Arg.Any()); _ = _reservations.Cancel(_reservationKey, Arg.Any()); }); [Fact] void should_report_known_noncommit() => _result.Recovery!.CommitDisposition.ShouldEqual(CommandCommitDisposition.NotCommitted); [Fact] void should_report_observed_recovery_completion() => _result.Recovery!.Status.ShouldEqual(CommandRecoveryStatus.Completed); [Fact] async Task should_not_persist_the_rejected_event() => (await _scenario.EventScenario.EventLog.HasEventsFor(_newPartner)).ShouldBeFalse(); async Task Destroy() => await ((IAsyncDisposable)this).DisposeAsync(); ValueTask IAsyncDisposable.DisposeAsync() => _scenario.DisposeAsync(); } ``` The explicit `IAsyncDisposable` implementation expresses resource ownership to SDK analyzers. `Destroy()` connects it to Specifications’ cleanup lifecycle and awaits the scenario’s asynchronous disposal. Run: ```bash dotnet test Onboarding.Specs ``` The constraint assertion checks a real configured constraint, not a mock that merely reports `NotCommitted`. The provider calls and operation assertions establish that work ran before rejection and that its compensator returned. The event-log assertion establishes that the rejected source received no event. ## Keep the guarantees separate This spec proves the in-process command/Chronicle integration for a **known rejection**. It does not simulate an uncertain commit acknowledgment, and it does not prove a production provider’s cancellation or the transport behavior of a remote Chronicle server. Do not replace the real constraint with a fabricated failed `CommandResult` and claim the same coverage. Conversely, use targeted integration/provider tests for uncertain outcomes instead of adding a real network dependency to every command decision spec. For the other side of the boundary, execute with a previously unused organization number and assert successful event persistence, completed operation execution, `Committed` disposition, and no provider cancellation. A later presentation failure is not permission to reverse an already-committed business action. Continue with [Chronicle transactional commands](/arc/backend/csharp/chronicle/commands/transactional-commands/) for transaction boundaries and [operation recovery](/arc/backend/csharp/commands/operations/reference/#commit-and-recovery) for conservative handling of committed or unknown outcomes. # Command scenarios > Configure and execute standalone command scenarios, and assert precise validation and authorization outcomes. `CommandScenario` exercises Arc’s real command pipeline. Use it when a direct `Handle()` call would miss validation, authorization, dependency resolution, or execution scopes. It also executes returned [command operations](/arc/backend/csharp/commands/operations/) and their eligible compensation with the services you register; it is not a recording-only simulator. Follow [testing operations](/arc/backend/csharp/testing/command-operations/) for a complete failure/recovery lesson. It complements fast decision specs; it is not a requirement for every command test. Start with [the decision-and-pipeline lesson](/arc/backend/csharp/testing/command-decisions/), or use [Testing](/arc/backend/csharp/testing/) to choose the right boundary. This page is the scenario API reference. []()[]() ## Package and API Use `Cratis.Arc.Testing` for standalone Arc tests, plus `Cratis.Specifications.XUnit` when writing Specifications. Chronicle is not required. The optional `Cratis.Testing` meta-package also brings Chronicle support; it is not the minimal standalone choice. Import `Cratis.Arc.Testing.Commands` for both the scenario and result assertion extensions. For commands with `[GeneratedValue]` Handle parameters, [pin generated values](/arc/backend/csharp/testing/generated-values/) before execution or opt into normal generation with `AllowGeneration()`. | Member | Contract | | --- | --- | | `Services` | `IServiceCollection` for registrations made before initialization | | `Context` | `IDictionary` populated by extenders | | `Execute(TCommand command)` | Returns `Task`; runs filters, argument resolution (including `Provide()`), the handler, response processing, and execution scopes | | `Execute(TCommand command, CancellationToken cancellationToken)` | Runs the same real pipeline with explicit forward cancellation; compensation uses its own cleanup token | | `LastResult` | The result from the latest `Execute`, including backend-only recovery observations | | `Operations` | Snapshot of started operation invocations from the latest `Execute`; empty before execution | | `Validate(TCommand command)` | Returns `Task`; runs pipeline filters but skips handler argument resolution, `Provide()`, `Handle()`, and execution scopes | | `Clock(DateTimeOffset instant)` | Fixes received times and `TimeProvider.GetUtcNow()` / `GetLocalNow()`; configure before the first `Execute` or `Validate` call | | `Tenant(TenantId tenant)` | Scopes execution and validation to this tenant and restores the caller’s tenant; immutable after first execution | | `Dispose()` / `DisposeAsync()` | Releases the provider and disposable context values | There is no typed `Execute` overload on the scenario. For a known successful response, cast to `CommandResult` and inspect its public `Response`, as in the [standalone checkpoint](/arc/backend/csharp/testing/#try-a-standalone-command-spec). The pipeline constructs that generic result from the returned value’s runtime type; do not assume a cast to a base/interface response type will work. Failure or no-response results need not have that generic type. []()[]()[]() ## Initialization and dependencies Instantiate the scenario in your spec. At construction it creates `Services` and `Context`, configures options and logging without a sink, then discovers and invokes `ICommandScenarioExtender` implementations. Extenders need a public parameterless constructor. Optional packages can therefore change the scenario’s services without a different base class. The first `Execute` or `Validate` call builds the service provider and resolves `ICommandPipeline`. Before that call, register application services, substitutes, and options through `Services`, usually in `Establish()`. Later registrations do not rebuild the provider. Discovered validators are constructed on demand using the command scope; you do not need to manually register every validator. The provider stays local to the scenario. It does not replace the process-wide provider an Arc host sets, so a scenario can run inside a live host or alongside other scenarios, and disposing it leaves the host untouched. Code that reads Arc’s process-wide provider directly, such as the MongoDB and EF Core `Observe()` extensions, therefore sees the host’s provider during a scenario, and throws `ServiceProviderNotConfigured` when no host has set one; command validation is unaffected because it resolves validators from the command’s own scope. This runs pipeline behavior, **not everything in your production host**. There is no HTTP routing, request binding, authentication middleware, or automatic copy of your host’s registrations. Application services may still access external infrastructure unless you replace them. Use host/integration tests for those boundaries. To opt into console logging while debugging, use this setup fragment before the first pipeline call (with `Microsoft.Extensions.DependencyInjection` and `Microsoft.Extensions.Logging` imported and the console logging package available): ```csharp _scenario.Services.AddLogging(logging => logging.AddConsole()); ``` []()[]() ## Validate without executing This complete test file uses the packages from the [standalone quick start](/arc/backend/csharp/testing/). It defines the command and validator so the message assertion has a known source. Compile it separately from the quick-start file, or give the types distinct names. ```csharp using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using FluentValidation; using Xunit; namespace ValidationSpecs; [Command] public record NormalizeName(string Name) { public string Handle() => Name.Trim(); } public class NormalizeNameValidator : CommandValidator { public NormalizeNameValidator() { RuleFor(command => command.Name) .NotEmpty() .WithMessage("Name is required"); } } public class when_validating_an_empty_name : Specification { readonly CommandScenario _scenario = new(); CommandResult _result = default!; async Task Because() => _result = await _scenario.Validate(new NormalizeName(string.Empty)); [Fact] void should_have_validation_errors() => _result.ShouldHaveValidationErrors(); [Fact] void should_report_the_required_name() => _result.ShouldHaveValidationErrorFor("Name is required"); void Destroy() => _scenario.Dispose(); } ``` Run `dotnet test`; both facts should pass. `Validate` still requires a discoverable command handler, but does not call it. A successful validation result does not prove that `Provide()`, `Handle()`, or a commit-time check will succeed during execution. Custom filters can also perform work, so validation is not a universal side-effect-free sandbox. ## CommandResult assertion helpers These extension methods return `void`. Built-in assertion failures throw `CommandResultAssertionException`. After its own check passes, **every** helper below applies discovered `ICommandResultAssertionPolicy` implementations through `CommandResultAssertionPolicies`; a policy can still fail the assertion or throw its own exception. | Method | Built-in check | | --- | --- | | `ShouldBeSuccessful()` | `IsSuccess` is true; includes failure reasons otherwise | | `ShouldNotBeSuccessful()` | `IsSuccess` is false | | `ShouldBeValid()` | `IsValid` is true | | `ShouldHaveValidationErrors()` | `IsValid` is false, and the validation results are not exclusively `DependencyUnavailable` | | `ShouldHaveValidationErrorFor(string message)` | A validation result’s message contains the text using ordinal, case-sensitive matching; this is **not** a property-path lookup | | `ShouldHaveValidationErrorBecauseOf(ValidationResultReason reason)` | A validation result carries that reason | | `ShouldHaveConstraintViolationFor(string constraintName)` | A validation result has reason `ConstraintViolation` and `ReasonDetail` exactly equal to the name | | `ShouldBeAuthorized()` | `IsAuthorized` is true | | `ShouldNotBeAuthorized()` | `IsAuthorized` is false | | `ShouldNotHaveExceptions()` | `HasExceptions` is false | | `ShouldHaveExceptions()` | `HasExceptions` is true | []() A broad failure assertion can pass for the wrong reason. Pair it with a specific message, reason, or constraint assertion. Message matching fails if the message is reworded to remove the expected text; it does not silently become a no-op. For optional Chronicle constraints, prefer `ShouldHaveConstraintViolationFor` with your application’s constraint-name constant instead of relying on prose. ## Operation assertions These assertions are available directly on `CommandScenario` and as extension methods on `CommandResult` through `Cratis.Arc.Testing.Commands`: | Method | Check | | --- | --- | | `ShouldHaveExecutedOperation()` | At least one invocation of that exact operation type returned successfully from `Execute()`. | | `ShouldHaveCompensatedOperation()` | At least one invocation of that exact type returned successfully from `Compensate()`. | | `ShouldHaveNoOperationInvocations()` | No operation entered `Execute()`, including invocations that partially executed and threw. | Scenario assertions require a completed `Execute()` call; they do not pass vacuously before execution. `LastResult` and `Operations` describe the latest execution, not the latest `Validate()` call. When several operations have the same type, use the per-invocation observations and provider assertions to prove counts, arguments, and order; the type-based helpers assert at least one match, not every instance. An invocation that throws can appear in `Operations` with `ExecutionCompleted` false and still have completed compensation. `ShouldHaveExecutedOperation` intentionally does not count that partial invocation as successful execution. These helpers throw `CommandResultAssertionException` on mismatch; they inspect operation observations rather than applying the general command-result assertion policy chain described above. See [testing operations](/arc/backend/csharp/testing/command-operations/), [failure cases](/arc/backend/csharp/testing/command-operation-failures/), and [Chronicle commit rejection](/arc/backend/csharp/testing/command-operations-with-chronicle/) for complete examples. ## Dependency unavailable is not a business-rule rejection A missing required read model can reject a command before its validator is constructed. `ShouldHaveValidationErrors()` deliberately fails when **all** validation results have reason `DependencyUnavailable`: otherwise a spec might pass without the business rule ever running. If other validation reasons are present too, the broad assertion can pass; it still does not identify which rule ran. Seed/register the required state when testing a business rule. When unavailable state is itself the intended outcome, assert the reason explicitly. This is an assertion fragment for a spec with `_result` already assigned; import `Cratis.Arc.Validation`: ```csharp [Fact] void should_reject_unavailable_state() => _result.ShouldHaveValidationErrorBecauseOf(ValidationResultReason.DependencyUnavailable); ``` This distinguishes a registered provider returning missing required state or an unusable key from an unregistered required service, which can instead produce an exception outcome. See [ARC0006](/arc/backend/csharp/code-analysis/arc0006/) for the resolution distinction and the [optional Chronicle seeding helpers](/arc/backend/csharp/testing/chronicle/#testing-commands-that-take-read-model-dependencies) for event-sourced tests. []() ## Supply a principal for authorization Pipeline authorization reads `ICurrentPrincipalAccessor`. Test its behavior by supplying a principal; this does not test login or token validation. The following complete standalone test file additionally requires `NSubstitute`: ```csharp using System.Security.Claims; using System.Threading.Tasks; using Cratis.Arc.Authorization; using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Xunit; namespace AuthorizationSpecs; public enum ApplicationRole { Administrator, User } [Command] [Roles(nameof(ApplicationRole.Administrator))] public record RunAdministration() { public string Handle() => "Completed"; } public class when_a_regular_user_runs_administration : Specification { readonly CommandScenario _scenario = new(); CommandResult _result = default!; void Establish() { var principalAccessor = Substitute.For(); principalAccessor.Current.Returns(new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.Role, nameof(ApplicationRole.User))], authenticationType: "test"))); _scenario.Services.AddSingleton(principalAccessor); } async Task Because() => _result = await _scenario.Execute(new RunAdministration()); [Fact] void should_not_be_authorized() => _result.ShouldNotBeAuthorized(); void Destroy() => _scenario.Dispose(); } ``` Run `dotnet test`; the fact should pass because the authenticated user lacks the required role. Arc pipeline authorization and host-level policy/scheme enforcement are distinct boundaries. ## Disposal The scenario owns its provider and disposable values in `Context`. Keep a field-initialized scenario `readonly` when the spec does not replace it; a field assigned or replaced in `Establish()` can remain mutable. `Specification` already implements xUnit’s `IAsyncLifetime` and calls `Destroy()` by convention for cleanup. Use `void Destroy() => _scenario.Dispose();`; no additional disposal interface is needed on the spec class. With plain xUnit, use the test framework’s supported disposal lifecycle. Asynchronous scenario disposal prefers `IAsyncDisposable` on owned values and falls back to `IDisposable`. Disposal is idempotent. Calling `Execute` or `Validate` afterwards throws `ObjectDisposedException`. Dispose per scenario to avoid accumulating providers during long test runs. ## Next steps * [Query scenarios](/arc/backend/csharp/testing/query-scenario/) run model-bound snapshot queries through Arc’s query pipeline. * [Optional Chronicle testing](/arc/backend/csharp/testing/chronicle/) adds in-process event-log assertions and seeded read model state. * [Command pipeline](/arc/backend/csharp/commands/command-pipeline/) explains the production execution path. # Test an event-sourced command > Combine direct decision specs with Arc–Chronicle pipeline specs that check event content, identity, and append outcomes. A command returning the right event is a good start. For an event-sourced application, also prove that Arc and Chronicle append that event to the **intended event source**, and that rejection appends nothing. Use direct specs for the decision and the Chronicle extension to `CommandScenario` for the append contract. You do not need an external Chronicle server for these in-process checks. ## Set up an integration spec project Use the current .NET SDK and current Cratis packages. Keep this project separate from the standalone shipping lesson so its optional Chronicle extender does not change that lesson’s test environment: ```bash dotnet new classlib -n Catalog dotnet add Catalog package Cratis.Arc.Chronicle dotnet new xunit -n Catalog.Specs dotnet add Catalog.Specs reference Catalog/Catalog.csproj dotnet add Catalog.Specs package Cratis.Specifications.XUnit dotnet add Catalog.Specs package Cratis.Arc.Chronicle.Testing dotnet add Catalog.Specs package Cratis.Arc ``` The explicit `Cratis.Arc` reference supplies the Arc ASP.NET Core assembly and the `Microsoft.AspNetCore.App` shared framework used by the in-process testing infrastructure. The current Chronicle testing package does not bring both prerequisites transitively into this isolated class-library setup. This reference does not start an HTTP host or an external Chronicle server. Remove the generated placeholder class/test if present. Keep application and spec projects on compatible current Cratis packages. The in-process Chronicle testing package has its own target-framework requirements; a test project’s target need not be identical to an older application target. ## Keep identity creation out of the decision Create `Catalog/RegisterAuthor.cs`: ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Chronicle.Events; using Cratis.Concepts; namespace Catalog; public record AuthorId(Guid Value) : EventSourceId(Value) { public static readonly AuthorId NotSet = new(Guid.Empty); public static AuthorId New() => new(Guid.NewGuid()); public static implicit operator AuthorId(Guid value) => new(value); } public record AuthorName(string Value) : ConceptAs(Value) { public static readonly AuthorName NotSet = new(string.Empty); public static implicit operator AuthorName(string value) => new(value); } [Command] public record RegisterAuthor(AuthorName Name) { public AuthorId Provide() => AuthorId.New(); public (AuthorId, AuthorRegistered) Handle(AuthorId authorId) => (authorId, new AuthorRegistered(Name)); } /// Records that an author was registered with a name. [EventType] public record AuthorRegistered(AuthorName Name); ``` The concepts have different jobs. `AuthorName` gives an ordinary value its domain meaning. `AuthorId` additionally tells Chronicle that the value identifies an event source. Reuse both within the author feature; in an application, shared concepts normally live in their own files there. `Provide()` creates the new identity. `Handle()` is deterministic for a given command and supplied identity: it returns the identity and a fact containing the name. The event does not repeat its own event-source ID; Chronicle stores that in event context. This example deliberately focuses on identity and appending. Add a `ConceptValidator` for name invariants in the application; [the standalone testing lesson](/arc/backend/csharp/testing/command-decisions/) demonstrates how to prove concept validation through Arc. ## Specify the returned decision directly Create `Catalog.Specs/for_RegisterAuthor/when_deciding_to_register_an_author.cs`: ```csharp using Catalog; using Cratis.Specifications; using Xunit; namespace Catalog.Specs; public class when_deciding_to_register_an_author : Specification { readonly AuthorId _id = AuthorId.New(); readonly AuthorName _name = "Sample Author"; AuthorId _returnedId = AuthorId.NotSet; AuthorRegistered _event = null!; void Because() => (_returnedId, _event) = new RegisterAuthor(_name).Handle(_id); [Fact] void should_return_the_supplied_identity() => _returnedId.ShouldEqual(_id); [Fact] void should_record_the_name() => _event.Name.ShouldEqual(_name); } ``` This is plain C# testing. It does not call `Provide()`, append an event, or validate the command. The supplied identity makes the decision easy to test without controlling a random-number generator or mocking an event log. Direct specs are a good place for all the decision branches. They are not a replacement for the next boundary. ## Prove the response and appended identity agree Create `Catalog.Specs/for_RegisterAuthor/when_registering_an_author_through_arc.cs`: ```csharp using Catalog; using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Chronicle.Events; using Cratis.Specifications; using Xunit; namespace Catalog.Specs; public class when_registering_an_author_through_arc : Specification { readonly CommandScenario _scenario = new(); readonly AuthorName _name = "Sample Author"; CommandResult _result = null!; async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_name)); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] void should_append_one_event() => _scenario.AppendedEvents.Count.ShouldEqual(1); [Fact] async Task should_append_the_name_under_the_returned_identity() { var response = ((CommandResult)_result).Response!; response.ShouldNotEqual(AuthorId.NotSet); await _scenario.ShouldHaveAppendedEvent( (EventSourceId)response, @event => @event.Name == _name); } void Destroy() => _scenario.Dispose(); } ``` Run: ```bash dotnet test Catalog.Specs ``` The five facts across the two specs should pass. The scenario spec exercises identity creation, argument resolution, response handling, and the in-process append. The final assertion searches the captured append by the **returned identity and expected payload**; the separate count assertion rules out duplicate appends. This catches a mistake a direct test could miss: `(Guid, AuthorRegistered)` can return a plausible GUID and event while the integration uses a different fallback event-source ID. Returning `AuthorId : EventSourceId` makes the intent explicit. [ARCCHR0010](/arc/backend/csharp/chronicle/code-analysis/arcchr0010/) warns about suspicious signatures, while this behavioral spec protects the actual contract. ## Prove rejection for the intended reason For a rejected command, assert its specific validation reason or named constraint **and** that it appended no new events. A broad “not successful” assertion can otherwise pass because a dependency was unavailable or the host was misconfigured. Seed the state required by the rule before executing. Choose between historical events and a pinned read-model instance deliberately; they are not interchangeable setup shortcuts. The [Chronicle scenario reference](/arc/backend/csharp/testing/chronicle/) shows those builders and precise assertions. Captured append lists can include setup appends, so compare the post-action additions or establish the appropriate baseline rather than assuming the list always starts empty. ## Test each additional boundary on purpose | Question | Appropriate next test | | --- | --- | | Does a sequence of events produce the right read model? | `ReadModelScenario` with representative history and expected state | | Does a reactor decide on the right follow-up? | Direct handler spec or `ReactorScenario`, with deliberate dependency setup | | Does the application discover and register its real reactor? | Hosted integration test using the real application composition | | Does a command-triggered automation actually finish? | Hosted integration test that waits for the intended observer/event outcome | | Do database mappings, serialization, and constraints work with the deployed provider? | Provider-backed integration test | | Does the caller receive the expected HTTP result and generated-client value? | Host/transport or full-stack test | A successful `CommandScenario.Execute()` is not proof that every projection and reactor in a running application has finished. Do not import that assumption from another framework’s asynchronous test harness. For live observer tests, wait on an observable completion condition with a deadline—not an arbitrary sleep. This combination keeps most business specs quick and focused, while a smaller set of boundary tests proves that the framework, storage, and application are connected correctly. Continue with [choosing a test boundary](/arc/backend/csharp/testing/#choose-the-boundary-that-can-catch-the-bug) and the [Chronicle testing reference](/arc/backend/csharp/testing/chronicle/). # Pin generated values in command specs > Choose server-generated identifiers by name in command scenarios, fix the clock and tenant, and inspect validated captures. A spec for `PlaceOrder` cannot assert on an order identifier that the server invents at random. It also should not make the caller supply one just so the spec can. With a [generated Handle parameter](/arc/backend/csharp/commands/generated-values/), the request contract stays unchanged and the spec chooses the value with `PinGeneratedValue`. The examples use the shop from the [Generated Values Shop sample](https://github.com/Cratis/Samples/tree/main/GeneratedValues/Shop), specs from its `Shop.Specs` project. Use `Cratis.Arc.Testing` and import `Cratis.Arc.Testing.Commands`; the shop’s specs also import `Cratis.Arc.Chronicle.Testing.Commands` for `ShouldHaveAppendedEvent`. For a standalone setup, follow the [testing quick start](/arc/backend/csharp/testing/#try-a-standalone-command-spec). ## Pin the identifier before executing `PlaceOrder` has a generated `orderId` parameter. The spec pins it by that name, executes the command and checks that the handler received the pin: Shop.Specs/Orders/Placing/for_PlaceOrder/when_placing_an_order/with_a_pinned_order_id.cs ```csharp using Shop.Orders.Placing; namespace Shop.Specs.Orders.Placing.for_PlaceOrder.when_placing_an_order; public class with_a_pinned_order_id : Specification { readonly CommandScenario _scenario = new(); readonly CustomerId _customerId = new("cus_01m4t7txm0hrn5rwemy2dkwtgq"); readonly OrderId _orderId = new("order_01m4t7txm0fgx9p7tehmp6mprg"); CommandResult _result = null!; void Establish() => _scenario.PinGeneratedValue("orderId", _orderId); async Task Because() => _result = await _scenario.Execute(new PlaceOrder(_customerId)); void Destroy() => _scenario.Dispose(); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] void should_return_the_pinned_order_id() => ((CommandResult)_result).Response.ShouldEqual(_orderId); [Fact] Task should_place_the_order_on_the_pinned_id() => _scenario.ShouldHaveAppendedEvent(_orderId, @event => @event.CustomerId == _customerId); } ``` Run `dotnet test`. The facts prove that the handler receives your pin and that the event lands on it. The pin does not call `OrderId.New()` at all, and it still passes through normal concept validation. Arc captures only values that pass that validation, not rejected or unresolved values. Pin names are case-sensitive and match the attribute name, `orderId` here, or the C# parameter name when the attribute has none. Supply the actual concept, not its underlying string: an incompatible or null pin fails execution with `PinnedGeneratedValueHasWrongType`. Its message names the command, parameter, and expected and actual types, never the pinned value. ## Keep scenarios strict or opt into generation Scenarios are strict by default. A spec that forgets the pin fails, and the failure says how to fix it: Shop.Specs/Orders/Placing/for_PlaceOrder/when_placing_an_order/without_a_pin.cs ```csharp public class without_a_pin : Specification { readonly CommandScenario _scenario = new(); CommandResult _result = null!; async Task Because() => _result = await _scenario.Execute(new PlaceOrder(new CustomerId("cus_01m4t7txm0hrn5rwemy2dkwtgq"))); void Destroy() => _scenario.Dispose(); [Fact] void should_fail_the_command() => _result.ShouldNotBeSuccessful(); [Fact] void should_report_an_exception_not_a_validation_failure() => _result.ShouldHaveExceptions(); [Fact] void should_explain_how_to_pin_it() => _result.ExceptionMessages.Any(message => message.Contains("PinGeneratedValue(\"orderId\",", StringComparison.Ordinal)).ShouldBeTrue(); [Fact] void should_not_capture_a_value() => _scenario.GeneratedValues.All("orderId").ShouldBeEmpty(); } ``` An unpinned generated parameter fails the command with a `GeneratedValueNotPinned` exception outcome in `CommandResult`; `Execute` does not throw it to your spec. This is deliberate: a spec that silently accepts random values would pass for the wrong reasons. When the exact identifier is irrelevant, call `AllowGeneration()` before executing. It applies to unpinned values throughout the scenario, including nested commands, and explicit pins still win. This fragment uses `PlaceOrder` and `OrderId`: ```csharp using var scenario = new CommandScenario().AllowGeneration(); var customerId = new CustomerId("cus_01m4t7txm0hrn5rwemy2dkwtgq"); var first = await scenario.Execute(new PlaceOrder(customerId)); var second = await scenario.Execute(new PlaceOrder(customerId)); var all = scenario.GeneratedValues.All("orderId"); all.Count.ShouldEqual(2); scenario.GeneratedValues.Last("orderId").ShouldEqual(((CommandResult)second).Response); ``` A pin supplies the same value on every execution; it is not a queue of identifiers. For a guarded generated append target, reusing an occupied pin rejects the command with `ConcurrencyViolation` and `ReasonDetail = "GeneratedIdentifierCollision"`. `Last(name)` selects the latest capture with that name and **declared** type, failing if none exists. `All(name)` returns a read-only snapshot in resolution order across executions, or an empty list. Both include validated pins and nested-command captures. Avoid logging or tracing these values. ## Prove a generated-identifier collision Guarded generated parameters automatically enable the in-process log when you create the scenario. `Given.ForEventSource(id).Events(...)` seeds the command’s event log, so the freshness guard sees those events. Pin the occupied identifier, execute once, and assert the collision detail and unchanged log tail. This excerpt uses the `CreateSource`, `Identifier`, `Created` and `ResultShape` types from the [fresh-identifier spec context](https://github.com/Cratis/Arc/blob/main/Source/DotNET/Chronicle.Specs/Commands/for_CommandScenario_with_fresh_identifiers/given/a_fresh_identifier_scenario.cs). Import `Cratis.Arc.Testing.Commands`, `Cratis.Arc.Chronicle.Testing.Commands`, `Cratis.Arc.Validation` and `Cratis.Specifications`: ```csharp await using var scenario = new CommandScenario(); var id = new Identifier(Guid.Parse("fb6dd43a-b161-44b1-9baa-7b2e4a071680")); scenario.PinGeneratedValue("id", id); scenario.Given.ForEventSource(id).Events(new Created("existing")); var tailBefore = await scenario.EventLog.GetTailSequenceNumber(); var command = new CreateSource(ResultShape.Wrapper); var result = await scenario.Execute(command); result.ValidationResults.Single().Reason.ShouldEqual(ValidationResultReason.ConcurrencyViolation); result.ValidationResults.Single().ReasonDetail.ShouldEqual("GeneratedIdentifierCollision"); (await scenario.EventLog.GetTailSequenceNumber()).ShouldEqual(tailBefore); command.Calls.ShouldEqual(1); ``` The scenario selects this log before any events are seeded; no separate setup call is needed. `Unguarded` generated parameters do not enable freshness protection. See [Fresh identifiers](/arc/backend/csharp/chronicle/commands/fresh-identifiers/) for whole-source semantics and the direct-write exclusion. ## Fix the clock and tenant `OrderId` takes its time from the command, so its time part is something a spec can assert. Configure the scenario’s clock and tenant, allow generation, and read the time back out of the generated TypeID: Shop.Specs/Orders/Placing/for_PlaceOrder/when_placing_an_order/with_a_fixed_clock_and_tenant.cs ```csharp using Cratis.Arc.Tenancy; using Shop.Orders.Placing; namespace Shop.Specs.Orders.Placing.for_PlaceOrder.when_placing_an_order; public class with_a_fixed_clock_and_tenant : Specification { readonly DateTimeOffset _receivedAt = new(2026, 10, 13, 9, 0, 0, TimeSpan.Zero); readonly CustomerId _customerId = new("cus_01m4t7txm0hrn5rwemy2dkwtgq"); CommandScenario _scenario = null!; CommandResult _result = null!; OrderId _orderId = null!; void Establish() => _scenario = new CommandScenario() .Clock(_receivedAt) .Tenant(new TenantId("north")) .AllowGeneration(); async Task Because() { _result = await _scenario.Execute(new PlaceOrder(_customerId)); _orderId = ((CommandResult)_result).Response!; } void Destroy() => _scenario.Dispose(); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] void should_generate_an_order_type_id() => _orderId.Value.StartsWith("order_", StringComparison.Ordinal).ShouldBeTrue(); [Fact] void should_use_the_received_time_as_the_time_part() => TypeIdTime.Of(_orderId.Value).ShouldEqual(_receivedAt); [Fact] void should_capture_the_generated_order_id() => _scenario.GeneratedValues.Last("orderId").ShouldEqual(_orderId); } ``` `TypeIdTime.Of` is a small helper in the sample’s `Shop.Specs` project, not an Arc API. It decodes the 48-bit Unix millisecond timestamp at the start of the TypeID’s UUIDv7 suffix, so the spec asserts the time part without comparing the random bits. `Clock(DateTimeOffset)` replaces the scenario’s `TimeProvider`: `ReceivedAt`, injected `TimeProvider.GetUtcNow()` and `GetLocalNow()` use the same fixed instant. Timers and elapsed-time measurements remain real, and direct calls to `DateTimeOffset.UtcNow` or `TimeProvider.System` are not replaced. `Tenant(TenantId)` scopes both execution and validation to that tenant and restores the caller’s tenant afterwards. Both apply to nested commands and stay local to the scenario. The fixed tenant is part of the context but not of this `OrderId`: a TypeID has no tenant slot, so the tenant only scopes the execution. A concept that builds the tenant into its value is the case where a tenant assertion is worth writing. A fixed clock does **not** fix the random suffix of UUIDv7, ULID or TypeID. Assert the time and tenant parts, or pin the whole concept when you need an exact value. `Clock(default)` is not a request to use the system clock: unpinned context generation fails with `MissingGenerationTime`. ## Assert that a secret stays off the causation chain `ApiKey` is marked `[NotAudited]`. The shop proves it by executing `RevokeApiKey`, which takes the key as a command property, and reading the causation of the appended event: Shop.Specs/Customers/ApiKeys/for_RevokeApiKey/when_revoking_an_api_key.cs ```csharp using Cratis.Chronicle.EventSequences; using Shop.Customers.ApiKeys; namespace Shop.Specs.Customers.ApiKeys.for_RevokeApiKey; public class when_revoking_an_api_key : Specification { readonly CommandScenario _scenario = new(); readonly CustomerId _customerId = new("cus_01m4t7txm0hrn5rwemy2dkwtgq"); readonly ApiKey _apiKey = new("AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA"); CommandResult _result = null!; IDictionary _causation = null!; async Task Because() { _result = await _scenario.Execute(new RevokeApiKey(_customerId, _apiKey)); var appended = (List)_scenario.Context[ChronicleCommandScenarioExtender.AppendedEventsKey]; _causation = appended.Single().Event.Context.Causation.Last().Properties; } void Destroy() => _scenario.Dispose(); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] void should_record_the_customer_on_the_causation() => _causation["customerId"].ShouldEqual(_customerId.Value); [Fact] void should_leave_the_api_key_off_the_causation() => _causation.ContainsKey("apiKey").ShouldBeFalse(); [Fact] void should_not_write_the_api_key_anywhere_on_the_causation() => _causation.Values.Any(value => value.Contains(_apiKey.Value, StringComparison.Ordinal)).ShouldBeFalse(); } ``` This checks the causation chain only, which is all `[NotAudited]` covers. It says nothing about event payloads, responses or logs. ## Pin values for nested commands Pins belong to a command type and a name. For another command invoked through `ICommandPipeline`, use the generic form with both the scenario’s command type and the nested command type. This fragment assumes `_scenario` is a `CommandScenario` and the nested command is `PlaceOrder`: ```csharp _scenario.PinGeneratedValue("orderId", _orderId); ``` The generic form pins the child’s parameter without changing a same-named parameter on the parent. Both commands’ captures appear in `GeneratedValues`, ordered as they resolve. If several commands use the same name and declared type, `Last` and `All` combine those captures; choose distinct names when you need to tell them apart. ## Finish configuration before the first execution Once the first command executes, pins, generation policy, clock and tenant are immutable, even if that command fails or has no generated parameters. Later calls to either pin overload, `AllowGeneration()`, `Clock()` or `Tenant()` throw `GeneratedValuesMustBePinnedBeforeExecution`. Create a new scenario when you need different configuration. Validation alone neither generates values nor freezes pins, generation policy or tenant selection. It does initialize the service provider, so a later `Clock()` call throws `ScenarioClockMustBeConfiguredBeforeInitialization` without changing the clock. Configure the clock before validation too. Pins and captures are local to each scenario. ## Make pins recoverable into Screenplay When you [generate a Screenplay](/arc/backend/csharp/generating-a-screenplay/#recovering-generation-strategies), the generator turns pins into `for ""` fixtures or `generated = ""` lines, and it needs a concrete literal for that. The shop’s specs use literals for this reason: `new OrderId("order_01m4t7txm0fgx9p7tehmp6mprg")` and `new CustomerId("cus_01m4t7txm0hrn5rwemy2dkwtgq")`. A pin built from `X.New()` cannot be recovered. Direct concept construction and one-hop stable fields with literal initializers are recoverable. Text must match the recovered strategy: NanoID’s alphabet and size, ULID’s uppercase Crockford encoding, TypeID’s prefix and UUIDv7 suffix, or bytes’ encoding, case, length and unused trailing bits. For a UUID-backed concept such as `CartId`, construct it from `Guid.Parse("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee")`, as the shop’s `when_starting_a_cart` spec does. A mismatched shape, computed or repeated pin, conditional configuration, nested-command pin or `AllowGeneration()` leaves the scenario out with `SP0039`, and its command keeps any admitted generation contract. Non-default strategies need an ESM v15 cap or later, and Screenplay v15 rejects reuse of a generated identifier whose event source already has facts. These checks do not add strategy-shape validation to Arc’s runtime pins, which use normal concept validation. A literal pin does not supply a missing generation strategy. When the factory is unrecoverable (`SP0073`), a scenario pinning that value is withheld whole with `SP0039`; no fixture is emitted against an invented caller parameter or UUID strategy. Successful scenarios are also withheld when their command loses an event production, including dependent event, response and route assertions. A provably independent pre-generation rejection scenario can remain. These omissions apply in authoring-only output and under version caps too; they do not prevent your original Arc specs from pinning a custom factory. ### Pins for validated generated concepts Recoverable String-backed concept rules accompany their strategy in ESM v20. A pin must satisfy the concept’s rules as well as its encoding and size. When a successful Arc scenario pins a value that violates either, recovery leaves the scenario out with located `SP0039`; it preserves the validator, strategy and pin text rather than weakening the contract. The Screenplay reference evaluator classifies an invalid generated fixture as `Rejected(Contract)`, never a client validation failure or a reason to redraw the value. Non-opaque rules that Screenplay cannot prove still accompany generation and report Information `SP0078` (`PLAY0689`); each concrete pin is checked. Opaque validation cannot be reference-executed, so recovery keeps the validator and withholds generation and unsupported scenarios. A V19-or-earlier cap also withholds validated generation and its pins, without deleting rules to fit the older consumer. Existing rule-free pins keep their previous model version and behavior. See [Validated generation](/arc/backend/csharp/generating-a-screenplay/#validated-generation-and-esm-v20). ### Pattern, random and inline-rule fixtures ESM v21 recovery also accepts literal text pins for pattern and inline-rule generation, and integral pins for signed random generation. A random `ConceptAs` can be pinned with a direct construction such as `new ReceiptId(4L)`; the value must lie inside its recovered inclusive range. Pattern pins must belong to the exact pattern support, including appended checksum digits. Arc asks Screenplay’s reference runner to check these fixtures rather than sampling the factory or duplicating its pattern evaluator. An inline rule’s pin supplies the primitive result without executing the C# body; recovered concept validation still applies. Cratis C# rule bodies use Fundamentals’ native generation context, including Tenant formatting and compound `GenerateValue.*(context)` calls. Neither a fixed clock nor a pin proves an opaque body’s entropy, so recovery never admits a rule-generated identifier. Invalid or missing fixtures leave the successful scenario out with `SP0039`. A V20-or-earlier cap withholds v21 generation and dependent scenarios in both output modes, keeping the validator rather than deleting checksum rules. Your original Arc specs remain runnable; the cap limits the recovered model. ## Next steps * [Migrate generated identifiers](/arc/upgrading/generated-values/) when a handler still calls `New()` inside `Handle()`. * [Testing generated values in Fundamentals](/fundamentals/csharp/generating-values/testing/) for specs outside a command. # Query scenarios > Execute snapshot queries and observe buffered query emissions through Arc's pipeline without an HTTP server. Use `QueryScenario` when a static read-model query needs Arc’s argument conversion, validation, authorization, dependency resolution, or result rendering in a test. A direct method call does not exercise those boundaries. This is a reference for the in-process scenario; use a hosted test for routing, middleware, scheme authentication, or transport behavior. ## Package and API Install `Cratis.Arc.Testing` and import `Cratis.Arc.Testing.Queries`. The scenario is independent of Chronicle. | Member | Behavior | | --- | --- | | `Services` | Configure application services, policies, and `ICurrentPrincipalAccessor` before the first `Perform` or `Observe` call. | | `Context` | Values populated by discovered `IQueryScenarioExtender` implementations at construction. | | `Perform(methodName, arguments?, paging?, sorting?, cancellationToken?)` | Executes the named static method on `TReadModel` through the hosted query pipeline; returns `Task`. Use `nameof(TReadModel.Method)` for the method name, not a fully qualified name. Defaults are empty arguments, no paging, no sorting, and no cancellation. | | `Observe(methodName, arguments?, paging?, sorting?, timeout?, cancellationToken?)` | Returns `Task` for a subject or async enumerable. The first-emission timeout defaults to five seconds. Pipeline rejections appear in `Initial`. | | `QueryObservation.Initial` | The first `QueryResult`, including authorization or validation rejection. | | `QueryObservation.Next(timeout?, cancellationToken?)` | Returns the next buffered `QueryResult`; defaults to a five-second deadline and throws `TimeoutException` when no emission arrives. Cancellation affects this read, not the subscription. | | `QueryObservation.Dispose()` / `DisposeAsync()` | `Dispose` starts disposal without waiting; `DisposeAsync` waits for cleanup of the subscription and any identity-bound scope, not the subject. | | `Dispose()` / `DisposeAsync()` | Ends observations before releasing the scenario provider and disposable extender context values; repeated calls are safe. | The scenario discovers query methods on **only** `TReadModel` and builds their fully qualified names. Like [command scenarios](/arc/backend/csharp/testing/command-scenario/), it builds its provider lazily. Register dependencies before the first call; later changes to `Services` do not affect that provider. Extenders need a public parameterless constructor. The scenario disposes any identity-bound scope returned by hosted authorization before `Perform` returns; a snapshot’s data remains in the result. Lazy snapshot sequences are materialized before the identity-bound scope is released. This fragment assumes `OrderSummary` is a `[ReadModel]` with `public static OrderSummary ById(string id)` and that its dependencies, if any, have been registered: ```csharp using Cratis.Arc.Queries; using Cratis.Arc.Testing.Queries; using var scenario = new QueryScenario(); var result = await scenario.Perform(nameof(OrderSummary.ById), new QueryArguments { ["id"] = "order-123" }); Assert.True(result.IsSuccess); var order = (OrderSummary)result.Data; ``` The in-repository query scenario specs in `Source/DotNET/Testing.Specs/Queries/for_QueryScenario/` exercise a parameterless query, concept argument conversion, rejection, and disposal with real read-model methods. ## Authorization and limits Register an `ICurrentPrincipalAccessor` in `Services` for the principal under test, as you do for [command authorization](/arc/backend/csharp/testing/command-scenario/#example-authorization-spec). There is **no principal parameter** on `Perform`. Use an `ICurrentPrincipalAccessor` or a principal-selecting policy runtime, not both; combining them can cause the runtime-selected identity and the accessor’s identity to disagree. Register host-neutral named policies with `Services.AddArcAuthorizationPolicy(name)`; the hosted query path evaluates those policies before constructing protected dependencies. The result reports a denial through `IsAuthorized == false`. Missing required query arguments appear in `ValidationResults` and make `IsValid` false. Unknown policy names fail at run time. Tenant selection is not configurable in a query scenario; it uses default tenant resolution. Authentication-scheme-restricted queries cannot be exercised in-process: selecting a scheme requires a live ASP.NET Core HTTP context. They fail closed with an unauthorized result. Use an HTTP-hosted integration spec to check schemes, request authentication, and ASP.NET Core-host policy behavior. See [ADR 0004](https://github.com/Cratis/Arc/blob/main/decisions/0004-evaluate-authorization-policies-asynchronously.md). ## Observable queries Use `Perform` to assert the first emission of an `ISubject` or `ISubject>` query. It waits up to 30 seconds, like HTTP with `waitForFirstResult=true`, then releases the subscription. A timeout or completion before the first value becomes an error result. `Perform` still rejects `IAsyncEnumerable` before invocation with `StreamingQueryNotSupported`; use `Observe` for that shape. Use `Observe` when the spec changes state and needs to assert a later emission. This fragment assumes a `[ReadModel]` named `OrderSummary` with an `[AllowAnonymous]` static query `Current(BehaviorSubject subject)` returning `ISubject`, and an `OrderSummary(string Status)` constructor: ```csharp using System.Reactive.Subjects; using Cratis.Arc.Testing.Queries; using Microsoft.Extensions.DependencyInjection; using var subject = new BehaviorSubject(new("Pending")); await using var scenario = new QueryScenario(); scenario.Services.AddSingleton(subject); await using var observation = await scenario.Observe(nameof(OrderSummary.Current)); Assert.Equal("Pending", ((OrderSummary)observation.Initial.Data).Status); subject.OnNext(new("Shipped")); var changed = await observation.Next(); Assert.Equal("Shipped", ((OrderSummary)changed.Data).Status); ``` The subscription buffers values immediately, so the synchronous change above is retained even though `Next` is called afterward. `Next` returns the **next emission, not the next distinct value**: replayed or duplicate values still occupy a position in the buffer. Collection emissions are materialized as `List`; paging totals are captured when each value is emitted. Each emission runs read-model interceptors and emission guards in order when you read it. Suppression skips a value; denial returns an unauthorized result and ends the subscription. Stream errors and completion return error results rather than successful empty data. `Observe` and `Next` throw `TimeoutException` on their deadlines; a `Next` timeout leaves the subscription live. An observation’s `Dispose` starts disposal without waiting for cleanup. Use `DisposeAsync` or dispose the scenario to wait for cleanup; the scenario ends observations before releasing its provider. The scenario does not restore ambient tenant or identity on a producer thread. Guards receive the admitted principal explicitly. MongoDB `.Observe()` still needs a real MongoDB provider and change-stream support; these scenarios do not emulate it. For Chronicle-backed queries, the testing extension also supports [observable read-model collections](#observe-chronicle-read-models). ## Chronicle-backed state With `Cratis.Arc.Chronicle.Testing` referenced, the discovered Chronicle extender adds in-memory read-model services. Seed by event source before `Perform`: ```csharp using Cratis.Arc.Chronicle.Testing.Queries; using var scenario = new QueryScenario(); var sourceId = EventSourceId.New(); scenario.Given.ForEventSource(sourceId).ReadModel(new QueryAccountBalance(42m)); var result = await scenario.Perform(nameof(QueryAccountBalance.ById), new QueryArguments { ["id"] = sourceId.Value }); Assert.True(result.IsSuccess); ``` `Given.ForEventSource(id).ReadModel(otherReadModel)` can pin another read model type used by the query. `Given.ForEventSource(id).Events(events)` also seeds history for projection on demand. The read-model query must resolve `IReadModels` from its method dependencies and read the seeded event source id; seeding does not replace arbitrary application stores or make HTTP endpoints available. See the Chronicle query spec `Source/DotNET/Chronicle.Specs/Queries/for_QueryScenario/when_a_chronicle_read_model_is_seeded.cs` for a complete tested example. ## Observe Chronicle read models Use `Observe` to assert both the initial collection and later changes from a query returning `readModels.Observe(filter)`. The scenario folds its events through your application’s discovered projections or reducers; it does not require a running Chronicle server. The optional filter applies to the merged collection, including pins. This illustrative excerpt assumes `AccountBalance` has an event-source-keyed projection from `BalanceChanged(decimal Balance)` and an `[AllowAnonymous]` static query `All(IReadModels readModels)` returning `readModels.Observe()`: ```csharp using Cratis.Arc.Chronicle.Testing.Queries; using Cratis.Arc.Testing.Queries; using Cratis.Chronicle.Events; await using var scenario = new QueryScenario(); var sourceId = EventSourceId.New(); scenario.Given.ForEventSource(sourceId).Events(new BalanceChanged(10m)); await using var observation = await scenario.Observe(nameof(AccountBalance.All)); Assert.Equal(10m, ((IEnumerable)observation.Initial.Data).Single().Balance); scenario.Given.ForEventSource(sourceId).Events(new BalanceChanged(42m)); var changed = await observation.Next(); Assert.Equal(42m, ((IEnumerable)changed.Data).Single().Balance); ``` The initial emission includes events and read models seeded through `Given`. In default mode, observation creates its own private log on the first `Observe` call. `Given.Events` never enters the command log, so it cannot shift command sequence numbers, rehydrate aggregates, or affect command constraints. The private log also mirrors successful appends from the command log, including `EventScenario.Given` seeds and command or operation appends. Later `Given.ForEventSource(id).Events(...)` and `.ReadModel(...)` update the observation too. If the same facts are seeded in both histories, observation processes both copies. A command scenario created independently has its own log and cannot update this query. `Next` reads buffered emissions; dispose the observation and scenario when the spec ends. Pins win over projected instances for their seeded key. To merge a pin safely, the read model must expose a readable identifier: Chronicle’s `[Key]`, then `[Subject]`, then a property named `Id`, including attributes on positional record parameters. If a pinned instance has no readable identifier or its identifier value is null, `Observe` throws `PinnedReadModelCannotBeObserved`, naming the type and advising you to seed events instead of pinning, or give the read model a key property. The identifier must also have a value in each emitted instance; a null key fails the stream rather than producing an ambiguous merge. For a named key property, you can populate it explicitly with `[SetFromContext(nameof(EventContext.EventSourceId))]`. Adding an unsupported pin to an active observation also fails the stream. Event-only observation does not require an identifier property. Unknown and passive read models retain Chronicle’s own observation errors. ### Children, joins, and shared command scenarios Default-mode observation supports model-bound `ChildrenFrom` mappings, cross-source joins, fluent projections, and reducers. The private event scenario observes through its own read-model surface, so the fold uses the same storage as its event history. You do not need decision mode to observe children or joins. To observe appends from a command scenario, share its services and context with the query **before seeding or initializing either scenario**. The setup works in default mode or with a [decision-mode command scenario](/arc/backend/csharp/testing/chronicle/#protected-decision-scenarios). The command scenario automatically selects decision mode when its command needs it; there is no public `QueryScenario.EnableDecisionReads()` method. This setup excerpt assumes `ChangeBalance` and `AccountBalance` are your application’s command and read model: ```csharp using Cratis.Arc.Chronicle.Testing.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Arc.Testing.Queries; await using var commands = new CommandScenario(); await using var queries = new QueryScenario(); foreach (var resource in queries.Context.Values.Distinct().OfType()) { resource.Dispose(); } queries.Context.Clear(); queries.Services.Clear(); foreach (var service in commands.Services) { queries.Services.Add(service); } foreach (var (key, value) in commands.Context) { queries.Context[key] = value; } ``` Now seed with `queries.Given.ForEventSource(id).Events(...)`, observe with `queries.Observe(...)`, and execute commands through `commands.Execute(...)`. In default mode, the shared observation history combines the read-model seeds and successful command appends without changing the command log. In decision mode, both scenarios use the same log for seeds, commands, and observation. The scenario does not emulate Chronicle’s observer lifecycle, persistent storage, or transport; use hosted integration specs for those boundaries. # Arc for Kotlin and Java > Understand how Arc removes endpoint plumbing from Spring Boot applications with compile-time, model-bound commands and queries. ## From endpoint plumbing to application models A conventional Spring application repeats transport concerns in controllers, service adapters, request mapping, and client code. Arc moves that plumbing to compile time. You put behavior on a command or a read model, and KSP generates reflection-free handlers, query performers, artifact metadata, validation metadata, and TypeScript proxies. Arc is Kotlin-first and Java-first-class. Kotlin handlers can be synchronous or suspending. Java handlers can be synchronous or return `CompletionStage`. Both run through the same validation, authorization, result, and Spring MVC hosting contracts. ``` optional event response HTTP or generated TypeScript client Arc Spring Boot integration Command or query pipeline KSP-generated handler or performer Model-bound Kotlin or Java code Chronicle integration ``` ## Module map | Published identity | Purpose | | --- | --- | | `io.cratis:arc` | Arc commands, queries, authentication, identity, tenancy, introspection, results, and pipelines | | `io.cratis:arc-ksp` | KSP processor for Kotlin and Java source, manifests, concept and Jakarta validation metadata, stable diagnostics, and a checked ABI baseline | | Gradle plugin `io.cratis.arc` (`io.cratis:arc-gradle-plugin`) | Preferred JVM/KSP setup, one-shot plus observable TypeScript proxy generation, and a checked ABI baseline | | `io.cratis:arc-spring-boot-starter` | Spring Boot auto-configuration and servlet HTTP, SSE, and optional WebSocket hosting | | `io.cratis:arc-spring-data-jpa` | Spring Data JPA paging, read-model, transaction, and observable `Flow` adapters | | `io.cratis:arc-spring-data-mongodb` | Spring Data MongoDB paging, read-model, transaction, and change-stream observable `Flow` adapters | | `io.cratis:arc-openapi-spring-boot-starter` | OpenAPI 3.1 document generation and document routes | | `io.cratis:arc-observability-spring-boot-starter` | Micrometer observations and optional OpenTelemetry/SLF4J correlation | | `io.cratis:arc-chronicle-spring-boot-starter` | Optional tenant-aware Chronicle transactions, concurrency, read models, side effects, and scenarios | | `io.cratis:cratis` | The one dependency for an event-sourced Cratis application - a pure aggregator over Arc, its Spring Boot wiring, and the Chronicle integration | | `io.cratis:arc-testing` | In-process command, query, and observable-query scenarios; Chronicle contributes an optional in-memory extender | Arc targets Spring Boot, not a separate host-independent Core product. The module layout and Maven coordinates stay unchanged. Compiler/Gradle-consumed `artifacts`, `metadata`, `json`, and their transitive local type references remain Spring-free, enforced by `./gradlew checkSpringBoundary`. The check also resolves external superclass/interface/class-signature closure and rejects Spring on production tool compile/runtime classpaths, including file dependencies; it does not recursively expand optional external member APIs or implementation internals. This is a build-tool boundary, not a promise of another host. Chronicle and the persistence integrations remain optional. Dedicated Kotlin and Java Chronicle sample modules demonstrate returned events, exact concurrency, tenant-local command read models, generated queries, and generated TypeScript contracts without changing the standalone samples. ## Current status Released versions of these coordinates, including the `io.cratis.arc` plugin marker, are published to [Maven Central](https://central.sonatype.com/artifact/io.cratis/arc). A local checkout builds as `0.0.0-SNAPSHOT` unless Gradle receives `-Pversion`; that snapshot is not published. Implemented behavior includes model-bound commands and `provide`; one-shot and observable queries; HTTP snapshots, SSE, WebSocket, and multiplexed observable hubs; automatic Jakarta command and typed query-argument graph validation; reusable concept validators; scalar concept metadata in TypeScript and OpenAPI; authentication, authorization, identity, tenancy, users, tenants, and endpoint introspection; query renderers, read-model interceptors, observable emission guards, and health; Spring Data JPA/Mongo observable `Flow` support; tenant-aware Chronicle transactions, concurrency, read models, side effects, and scenarios; OpenAPI and observability starters; Kotlin conveniences and Java Core adapters; stable KSP diagnostics; ABI baselines; a differential gate against a normalized .NET-derived proxy fixture; a TypeScript runtime harness with five wired unit tests and 35 behavioral E2E tests; bounded runtime limits; and Kotlin/Java testing support. Generated clients map `LocalDate`, `LocalTime`, and `UUID` to `DateOnly`, `TimeOnly`, and `Guid`, serialize commands and GET parameters as scalar strings, and hydrate returned generated models into class instances. Arc’s Core and Spring mappers use ISO-8601 strings for `Duration`; TypeScript remains `string` and OpenAPI is `string`/`duration`, not Fundamentals `TimeSpan`. `OffsetTime` also generates as textual, untyped `string`; its offset-specific semantics are not hydrated into a class. Introspection exposes UUID and supported terminal textual `java.time` values as scalar strings. Recursive type metadata, source summaries and typed command event defaults remain available in manifest format 8, which adds explicit property validation-ignore flags while preserving immutable Java-friendly shape descriptors and legacy constructor descriptors. Producer/dependency manifests and consumers must be regenerated together. Kotlin KDoc and Java Javadoc summaries flow through the manifest to runtime introspection and to generated TypeScript JSDoc. Generated performers inject service, `QueryRequest`, `QueryContext`, and exact Spring Data Commons `Pageable`/`Sort` parameters in declaration order and normalize exact `Page` results; Spring binding, validation, TypeScript, introspection, and OpenAPI expose only client query parameters. Arc.Kotlin still does not claim complete Arc .NET parity. Controllers and non-Spring hosting are not planned. Spring Data’s observable APIs require store notifications rather than hidden polling, OpenAPI omits the nonstandard QUERY operation, and Chronicle does not create a distributed transaction with application databases. Credit-card constraints are server-only until the pinned TypeScript client runtime exposes a compatible rule. Arc accepts and emits `LocalTime` with up to seven fractional digits for 100 ns compatibility, rejecting finer values during serialization rather than rounding or truncating them. Separately, temporal client limits include JavaScript `Date` zone/identity loss, proven `TimeOnly` truncation from raw `08:09:10.1235567` to hydrated `08:09:10.123`, and the shared generated client’s explicit QUERY-body `JSON.stringify` of `DateOnly`/`TimeOnly`; use GET until upstream serialization invokes the typed serializer or `toJSON()`. Calendar/UUID, tenant-safe Spring Data command read models, and nested Chronicle ownership are completed slices. The explicit Chronicle compatibility task runs the default Chronicle.Kotlin 6.10.0 client against a digest-pinned 19.22.2 development kernel, while cross-store partial and indeterminate outcomes remain application concerns. See the [feature parity reference](/arc/backend/kotlin/reference/parity/) for the complete status matrix and ordered P0 backlog. ## Choose a path New to Arc? Start with [why Arc for Kotlin and Java](/arc/backend/kotlin/why-arc-kotlin/), or, if you already know Spring MVC, [coming from Spring MVC](/arc/backend/kotlin/coming-from-spring-mvc/) maps the concepts directly. Then build the runnable [Kotlin tutorial](/arc/backend/kotlin/get-started/) or the equivalent [Java tutorial](/arc/backend/kotlin/get-started/java/). Use the topic guides for focused tasks, the [reference](/arc/backend/kotlin/reference/) for exact contracts, and [troubleshooting](/arc/backend/kotlin/troubleshooting/) when something does not behave as documented. # Coming from Spring MVC > Map familiar @RestController, validation, and dependency-injection conventions onto Arc's model-bound commands and queries. ## The mapping Arc replaces the controller layer, not Spring itself. Dependency injection, `@Configuration`, `@Bean`, and every other ordinary Spring Boot mechanism still work exactly as before — the difference is what you write to expose a command or a query over HTTP. | Spring MVC | Arc | | --- | --- | | `@RestController` class with a `@PostMapping` method | `@Command` class with a public `handle` method | | `@RestController` class with a `@GetMapping` method | `@ReadModel` class with a static or `@JvmStatic` companion query method | | `@RequestBody CreateTaskRequest body` | The command’s own constructor properties — the command *is* the request body | | `@RequestParam` / `@PathVariable` | Unannotated query method parameters | | `@Autowired` field or constructor injection into the controller | Unannotated `handle` parameters resolved from Spring by type; a query method needs `@FromServices` on each service parameter, because an unannotated query parameter is a client argument | | `@Valid @RequestBody X body, BindingResult result` | Automatic Jakarta validation on the command or query-argument graph when a `Validator` bean exists — see [Add validation](/arc/backend/kotlin/guides/commands/#add-validation) | | `ResponseEntity` | A `CommandResult` or `QueryResult` JSON envelope — see the [HTTP contract reference](/arc/backend/kotlin/reference/http-contract/) | | `@PreAuthorize("hasRole('ADMIN')")` | `@Authorize(roles = ["admin"])` or `@Roles("admin")` on the class or the operation — see [Protect the command](/arc/backend/kotlin/guides/commands/#protect-the-command) | | A hand-written OpenAPI annotation set, or springdoc | Arc generates OpenAPI 3.1 from the same metadata — see [Publish an OpenAPI document](/arc/backend/kotlin/guides/openapi/) | | A hand-written or generated `fetch`/axios TypeScript client | A generated, strict-mode TypeScript client — see [Generate TypeScript proxies](/arc/backend/kotlin/guides/typescript-proxies/) | | `SseEmitter` or a `WebSocketHandler` for live data | A query returning `Flow` or `Flow.Publisher` — Arc hosts HTTP snapshots, SSE, and WebSocket for it automatically — see [Declare observable queries](/arc/backend/kotlin/guides/observable-queries/) | ## A command, side by side * Java ```java // Spring MVC @RestController public class TaskController { private final TaskRepository repository; public TaskController(TaskRepository repository) { this.repository = repository; } @PostMapping("/api/create-task") public ResponseEntity create(@Valid @RequestBody CreateTaskRequest request) { var task = repository.create(request.title()); return ResponseEntity.ok(new TaskCreated(task.id(), task.title())); } } ``` * Kotlin ```kotlin // Spring MVC @RestController class TaskController(private val repository: TaskRepository) { @PostMapping("/api/create-task") fun create(@Valid @RequestBody request: CreateTaskRequest): ResponseEntity { val task = repository.create(request.title) return ResponseEntity.ok(TaskCreated(task.id, task.title)) } } ``` - Java ```java // Arc @Command @AllowAnonymous public record CreateTask(String title) { public TaskCreated handle(TaskRepository repository) { var task = repository.create(title); return new TaskCreated(task.id(), task.title()); } } ``` - Kotlin ```kotlin // Arc @Command @AllowAnonymous data class CreateTask(val title: String) { fun handle(repository: TaskRepository): TaskCreated { val task = repository.create(title) return TaskCreated(task.id, task.title) } } ``` There is no `TaskController` and no separate `CreateTaskRequest` DTO. `CreateTask` is the request body, `repository` is resolved from Spring the same way constructor injection would resolve it, and the route (`POST /api/create-task` by convention) is derived and generated by KSP rather than declared with `@PostMapping`. ## A query, side by side * Java ```java // Spring MVC @RestController public class TaskQueryController { private final TaskRepository repository; public TaskQueryController(TaskRepository repository) { this.repository = repository; } @GetMapping("/api/tasks") public List all() { return repository.all(); } } ``` * Kotlin ```kotlin // Spring MVC @RestController class TaskQueryController(private val repository: TaskRepository) { @GetMapping("/api/tasks") fun all(): List = repository.all() } ``` - Java ```java // Arc @ReadModel @AllowAnonymous public record TaskView(String id, String title) { @Path("/api/tasks") public static List all(@FromServices TaskRepository repository) { return repository.all(); } } ``` - Kotlin ```kotlin // Arc @ReadModel @AllowAnonymous data class TaskView(val id: String, val title: String) { companion object { @JvmStatic @Path("/api/tasks") fun all(@FromServices repository: TaskRepository): List = repository.all() } } ``` `@FromServices` exists precisely to distinguish `repository` — a dependency — from an unannotated parameter, which a caller supplies as a query argument. Without a query taking caller arguments, every parameter here happens to be a dependency; see [Add a query to a read model](/arc/backend/kotlin/guides/queries/#add-a-query-to-a-read-model) for a query that takes both. ## What does not change * Spring Security, `@ConfigurationProperties`, `@Scheduled`, `@Transactional` outside a command handler, and every other Spring Boot mechanism keep working exactly as they do today. * You can still write ordinary `@RestController` endpoints alongside Arc’s generated ones in the same application — Arc adds routes, it does not remove Spring MVC. * Spring Data JPA and MongoDB repositories are injected the same way; see [Use Spring Data read models](/arc/backend/kotlin/guides/spring-data/) for the paging, sorting, and read-model resolution Arc adds on top of them. ## Where to go next * [Build your first Arc application in Kotlin](/arc/backend/kotlin/get-started/) or [in Java](/arc/backend/kotlin/get-started/java/). * [Create and validate commands](/arc/backend/kotlin/guides/commands/) and [Expose one-shot and observable queries](/arc/backend/kotlin/guides/queries/) for the complete contract. * [Why Arc for Kotlin and Java](/arc/backend/kotlin/why-arc-kotlin/) for the problem this all removes. # Build your first Arc application with Kotlin > Create and run a minimal Kotlin Spring Boot command and query with Arc's generated, model-bound endpoints. By the end of this page you have a Spring Boot application that accepts a `CreateTask` command at `POST /api/create-task` and answers a `TaskView` query at `/api/tasks`. You write no controller: Arc generates both endpoints from the model at compile time. ## Prerequisites Use JDK 17 and Gradle 8.14.4. This tutorial follows the passing `Samples/Kotlin/SpringBoot` application in the repository. Arc’s libraries and Gradle plugin implementation are published to Maven Central under the `io.cratis` group; the `io.cratis.arc` plugin marker is `io.cratis.arc:io.cratis.arc.gradle.plugin`. Replace every `` below with the same Arc release, for example the latest version listed for [`io.cratis:arc` on Maven Central](https://central.sonatype.com/artifact/io.cratis/arc). Inside this repository the local workspace version is `0.0.0-SNAPSHOT`, which is not published. For Java records and `CompletionStage`, follow [Build your first Arc application with Java](/arc/backend/kotlin/get-started/java/). ### Compiler compatibility Use Kotlin 2.4.20 and KSP 2.3.12 (KSP2), as in the examples below. The Arc plugin applies exactly this pair itself; choose it yourself only for the manual setup. Arc’s compiled classes carry Kotlin metadata version 2.4.0; Kotlin 2.1.0 and 2.2.0 reject that metadata when compiling a consumer. Upgrade the consumer compiler rather than disabling metadata validation, and retain the Kotlin runtime versions selected by the dependency graph. JVM bytecode still targets Java 17. Compiler-generated forwarding methods for inherited interface defaults may appear in implementation classes’ `getDeclaredMethods()` results. Reflective lookup can return a class-declared forwarder instead of an inherited interface method, changing its declaring class and `Method.isDefault()` result. An added declared method does not necessarily represent a new handwritten implementation or a changed default. The repository requires Gradle 8.14.4 and JDK 17. Use the checked-in wrapper with Kotlin 2.4.20 and KSP 2.3.12; this baseline avoids the Kotlin plugin’s deprecated Gradle version warning without suppressing it. ## Configure Gradle Start in an empty project directory. The Arc plugin marker is published to Maven Central, so add Maven Central to plugin resolution in `settings.gradle.kts`: ```kotlin pluginManagement { repositories { mavenCentral() gradlePluginPortal() } } ``` Gradle only runs in a directory that contains a build definition, so create the wrapper now, with an installed Gradle, before `build.gradle.kts`. Every later `./gradlew` command then runs Gradle 8.14.4: ```bash gradle wrapper --gradle-version 8.14.4 ``` The preferred setup is the Arc plugin. It applies Kotlin/JVM and KSP, adds `io.cratis:arc` and `io.cratis:arc-ksp`, targets JDK 17, and treats warnings as errors. Create `build.gradle.kts` with the following content. Its `repositories` block resolves application and processor dependencies; `pluginManagement.repositories` alone does not resolve them. ```kotlin plugins { id("io.cratis.arc") version "" kotlin("plugin.spring") version "2.4.20" id("org.springframework.boot") version "4.1.1" id("io.spring.dependency-management") version "1.1.7" } repositories { mavenCentral() } cratisArc { moduleName.set("TaskApplication") dependencyVersion.set("") endpoints { segmentsToSkip.set(2) } } dependencies { implementation("io.cratis:arc-spring-boot-starter:") implementation("org.springframework.boot:spring-boot-starter-webmvc") } ``` If the plugin is not available in your build, use manual KSP setup: ```kotlin plugins { kotlin("jvm") version "2.4.20" kotlin("plugin.spring") version "2.4.20" id("com.google.devtools.ksp") version "2.3.12" id("org.springframework.boot") version "4.1.1" id("io.spring.dependency-management") version "1.1.7" } dependencies { implementation("io.cratis:arc:") implementation("io.cratis:arc-spring-boot-starter:") implementation("org.springframework.boot:spring-boot-starter-webmvc") ksp("io.cratis:arc-ksp:") } repositories { mavenCentral() } ksp { arg("arc.moduleName", "TaskApplication") } ``` Set the matching host route convention in `src/main/resources/application.properties`. The example package has two segments, so skipping both produces `/api/create-task`: ```properties cratis.arc.endpoints.segments-to-skip-for-route=2 ``` ## Add the application model Create `src/main/kotlin/example/tasks/Tasks.kt` with a Spring repository, a command, and a read model. Arc discovers the model and resolves parameters from Spring. Use public model types, properties, and invocation methods. Kotlin’s default public visibility is supported; the explicit `public` keywords below are style, not a compiler requirement. `@AllowAnonymous` makes the tutorial endpoints callable without authentication. ```kotlin package example.tasks import io.cratis.arc.artifacts.Command import io.cratis.arc.artifacts.FromServices import io.cratis.arc.artifacts.ReadModel import io.cratis.arc.authorization.AllowAnonymous import io.cratis.arc.queries.Path import java.util.UUID import java.util.concurrent.ConcurrentHashMap import org.springframework.stereotype.Repository @Repository public class TaskRepository { private val tasks = ConcurrentHashMap() public fun create(title: String): TaskView = TaskView(UUID.randomUUID().toString(), title.trim()).also { tasks[it.id] = it } public fun all(): List = tasks.values.sortedBy(TaskView::title) } public data class TaskCreated(public val id: String, public val title: String) @Command @AllowAnonymous public data class CreateTask(public val title: String) { public fun handle(repository: TaskRepository): TaskCreated { val task = repository.create(title) return TaskCreated(task.id, task.title) } } @ReadModel @AllowAnonymous public data class TaskView(public val id: String, public val title: String) { public companion object { @JvmStatic @Path("/api/tasks") public fun all(@FromServices repository: TaskRepository): List = repository.all() } } ``` Create `src/main/kotlin/example/TaskApplication.kt` so Spring scans the `example.tasks` package: ```kotlin package example import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication class TaskApplication fun main(args: Array) { runApplication(*args) } ``` Run the application: ```bash ./gradlew bootRun ``` The build runs KSP before compiling, so a model mistake surfaces as an `ARCKSP` compiler error rather than at startup; the [diagnostics reference](/arc/backend/kotlin/reference/diagnostics/) explains each code. When Spring Boot logs `Tomcat started on port 8080`, the generated endpoints are live. ## Execute the command ```bash curl -sS -X POST http://localhost:8080/api/create-task \ -H 'Content-Type: application/json' \ -d '{"title":"Try Arc"}' ``` The identifier changes on every run. The response has this shape: ```json {"correlationId":"","isAuthorized":true,"validationResults":[],"exceptionMessages":[],"exceptionStackTrace":"","authorizationFailureReason":"","isValid":true,"hasExceptions":false,"isSuccess":true,"response":{"id":"","title":"Try Arc"}} ``` ## Query the read model ```bash curl -sS -X QUERY http://localhost:8080/api/tasks \ -H 'Content-Type: application/json' \ -d '{"arguments":{}}' ``` The one-shot query returns the created task in a `QueryResult` envelope: ```json {"correlationId":"","data":[{"id":"","title":"Try Arc"}],"isReady":true,"isAuthorized":true,"validationResults":[],"exceptionMessages":[],"exceptionStackTrace":"","paging":{"page":0,"size":0,"totalItems":1,"totalPages":0},"isValid":true,"hasExceptions":false,"isSuccess":true} ``` The repository’s `:GradlePlugin:test --tests '*ArcOnboardingFunctionalTest'` check materializes the preferred Kotlin and Java tutorial files, resolves locally staged Arc plugin-marker and runtime publications, compiles generated artifacts, and sends these POST and QUERY requests to a real Spring Boot host on a random port. It substitutes only the Arc version and local Arc repository seams, with an added test probe; public transitive dependencies still use the documented repositories. This is separate from the source-only documentation snippet checker and does not compile every documentation snippet or execute the manual setup. If a request returns `404`, the route convention and the package disagree; [troubleshooting](/arc/backend/kotlin/troubleshooting/) walks through that and other first-run failures. Continue with the [commands guide](/arc/backend/kotlin/guides/commands/) and [queries guide](/arc/backend/kotlin/guides/queries/), or generate a typed frontend client with [TypeScript proxies](/arc/backend/kotlin/guides/typescript-proxies/). # Build your first Arc application with Java > Create and run a minimal Java Spring Boot command and query using records and CompletionStage. By the end of this page you have a Spring Boot application, written in Java records, that accepts a `CreateTask` command at `POST /api/create-task` and answers a `TaskView` query at `/api/tasks`. You write no controller: Arc generates both endpoints at compile time. ## Prerequisites Use JDK 17 and Gradle 8.14.4. This tutorial follows the passing `Samples/Java/SpringBoot` application. Arc’s libraries and Gradle plugin implementation are published to Maven Central under the `io.cratis` group; the `io.cratis.arc` plugin marker is `io.cratis.arc:io.cratis.arc.gradle.plugin`. Replace every `` below with the same Arc release, for example the latest version listed for [`io.cratis:arc` on Maven Central](https://central.sonatype.com/artifact/io.cratis/arc). Inside this repository the local workspace version is `0.0.0-SNAPSHOT`, which is not published. Arc generates Kotlin implementations for Java models, so the build also uses Kotlin 2.4.20 and KSP 2.3.12 (KSP2). The Arc plugin supplies this tooling; you write no Kotlin. See the [compiler compatibility notes](/arc/backend/kotlin/get-started/#compiler-compatibility), including the Gradle 8.14.4 requirement, when configuring the plugins manually or mixing Kotlin and Java sources. ## Configure Gradle Start in an empty project directory. The Arc plugin marker is published to Maven Central, so add Maven Central to plugin resolution in `settings.gradle.kts`: ```kotlin pluginManagement { repositories { mavenCentral() gradlePluginPortal() } } ``` Gradle only runs in a directory that contains a build definition, so create the wrapper now, with an installed Gradle, before `build.gradle.kts`. Every later `./gradlew` command then runs Gradle 8.14.4: ```bash gradle wrapper --gradle-version 8.14.4 ``` Create `build.gradle.kts` using the Arc plugin and Spring Boot starter. Include application dependency repositories here: `pluginManagement.repositories` only resolves plugins, not application or processor dependencies. ```kotlin plugins { java id("io.cratis.arc") version "" id("org.springframework.boot") version "4.1.1" id("io.spring.dependency-management") version "1.1.7" } repositories { mavenCentral() } cratisArc { moduleName.set("TaskApplication") dependencyVersion.set("") endpoints { segmentsToSkip.set(2) } } dependencies { implementation("io.cratis:arc-spring-boot-starter:") implementation("org.springframework.boot:spring-boot-starter-webmvc") } ``` For builds that do not use the Arc plugin, retain `repositories { mavenCentral() }` in `build.gradle.kts`, apply `java`, Kotlin/JVM `2.4.20`, KSP `2.3.12`, and Spring Boot directly; add `io.cratis:arc`, `io.cratis:arc-spring-boot-starter`, and `ksp("io.cratis:arc-ksp:")`, then set `ksp { arg("arc.moduleName", "TaskApplication") }`. The plugin and manual setup produce the same generated contracts. Set the matching host convention in `src/main/resources/application.properties`: ```properties cratis.arc.endpoints.segments-to-skip-for-route=2 ``` ## Add the application model Create each public type in its named Java file under `src/main/java/example/tasks/`. The command and query return `CompletionStage`; generated Arc adapters await them without reflection. `TaskCreated.java`: ```java package example.tasks; public record TaskCreated(String id, String title) {} ``` `TaskRepository.java`: ```java package example.tasks; import java.util.Comparator; import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import org.springframework.stereotype.Repository; @Repository public final class TaskRepository { private final ConcurrentHashMap tasks = new ConcurrentHashMap<>(); public TaskView create(String title) { var task = new TaskView(UUID.randomUUID().toString(), title.trim()); tasks.put(task.id(), task); return task; } public List all() { return tasks.values().stream().sorted(Comparator.comparing(TaskView::title)).toList(); } } ``` `CreateTask.java`: ```java package example.tasks; import io.cratis.arc.artifacts.Command; import io.cratis.arc.authorization.AllowAnonymous; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @Command @AllowAnonymous public record CreateTask(String title) { public CompletionStage handle(TaskRepository repository) { var task = repository.create(title); return CompletableFuture.completedFuture(new TaskCreated(task.id(), task.title())); } } ``` `TaskView.java`: ```java package example.tasks; import io.cratis.arc.artifacts.FromServices; import io.cratis.arc.artifacts.ReadModel; import io.cratis.arc.authorization.AllowAnonymous; import io.cratis.arc.queries.Path; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @ReadModel @AllowAnonymous public record TaskView(String id, String title) { @Path("/api/tasks") public static CompletionStage all(@FromServices TaskRepository repository) { return CompletableFuture.completedFuture(repository.all().toArray(TaskView[]::new)); } } ``` Create `src/main/java/example/TaskApplication.java` so Spring scans the `example.tasks` package: ```java package example; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class TaskApplication { public static void main(String[] args) { SpringApplication.run(TaskApplication.class, args); } } ``` Run the application: ```bash ./gradlew bootRun ``` KSP runs before compilation, so a model mistake surfaces as an `ARCKSP` compiler error; the [diagnostics reference](/arc/backend/kotlin/reference/diagnostics/) explains each code. When Spring Boot logs `Tomcat started on port 8080`, the generated endpoints are live. ## Execute and query ```bash curl -sS -X POST http://localhost:8080/api/create-task \ -H 'Content-Type: application/json' \ -d '{"title":"Try Arc"}' curl -sS -X QUERY http://localhost:8080/api/tasks \ -H 'Content-Type: application/json' \ -d '{"arguments":{}}' ``` The command returns `isSuccess: true` with a typed `response`. Identifiers change on every run: ```json {"correlationId":"","isAuthorized":true,"validationResults":[],"exceptionMessages":[],"exceptionStackTrace":"","authorizationFailureReason":"","isValid":true,"hasExceptions":false,"isSuccess":true,"response":{"id":"","title":"Try Arc"}} ``` The query returns `isSuccess: true` with the created task array in `data`: ```json {"correlationId":"","data":[{"id":"","title":"Try Arc"}],"isReady":true,"isAuthorized":true,"validationResults":[],"exceptionMessages":[],"exceptionStackTrace":"","paging":{"page":0,"size":0,"totalItems":0,"totalPages":0},"isValid":true,"hasExceptions":false,"isSuccess":true} ``` The current array-returning adapter leaves paging totals at zero; the Kotlin tutorial’s `List` return reports `totalItems: 1`. Neither example requests paging. See the [HTTP contract reference](/arc/backend/kotlin/reference/http-contract/) for the envelope contract. If a request returns `404`, see [troubleshooting](/arc/backend/kotlin/troubleshooting/) for the route convention. The [executable tutorial check](/arc/backend/kotlin/get-started/#query-the-read-model) compiles these Java files and generated Kotlin adapters with warnings as errors, then verifies the documented requests against a real Spring Boot host. It tests the preferred plugin setup using locally staged Arc publications, not every documentation snippet or the manual setup. ## Call pipelines from an imperative Java service Inject `io.cratis.arc.java.BlockingCommandPipeline` and `BlockingQueryPipeline` for an ordinary imperative service or scheduled job. The Spring starter supplies both beans and backs off when you supply your own. Unlike the `Blocking*Handler` adapters (which implement pipeline extension points), these facades let the **caller** execute, validate, and perform one-shot queries without a `Continuation`, `CompletionStage.join()`, or an owned executor. Supply `CommandExecutionOptions` or `QueryExecutionOptions` on every call. Capture the intended principal, tenant, correlation ID and `ServiceResolver` explicitly at entry; the facade neither reads request/security thread locals nor creates an anonymous identity. Authorization and validation still run through the configured pipelines. For example, a constructor-injected service can expose: ```java import io.cratis.arc.commands.CommandExecutionOptions; import io.cratis.arc.java.BlockingCommandPipeline; import io.cratis.arc.java.BlockingQueryPipeline; import io.cratis.arc.queries.QueryExecutionOptions; import io.cratis.arc.queries.QueryRequest; import io.cratis.arc.results.CommandResult; import io.cratis.arc.results.QueryResult; import org.springframework.stereotype.Service; @Service public final class TaskOperations { private final BlockingCommandPipeline commands; private final BlockingQueryPipeline queries; public TaskOperations(BlockingCommandPipeline commands, BlockingQueryPipeline queries) { this.commands = commands; this.queries = queries; } public CommandResult execute(Object command, CommandExecutionOptions options) { return commands.execute(command, options); } public CommandResult validate(Object command, CommandExecutionOptions options) { return commands.validate(command, options); // Does not invoke the command handler. } public QueryResult perform(QueryRequest request, QueryExecutionOptions options) { return queries.perform(request, options); } } ``` A scheduler has no incoming request: choose a configured application identity and tenant, and create fresh correlation/options for each scheduled invocation. Do not invent an authenticated principal from untrusted request data. A Java service with constructor injection and an explicitly invoked `@Scheduled` method is compiled and executed by `Integrations/SpringBoot`’s `BlockingPipelineJavaConformanceTest`; this does not test scheduler timing. `Source`’s `BlockingPipelineJavaConformanceTest` verifies context capture, authorization, validation nonexecution, results, failed/cancelled stages, and interrupted command cleanup. For deliberately bound context, construct `new BlockingCommandPipeline(pipeline, options)` to use `execute(command)` and `validate(command)`, or `new BlockingQueryPipeline(pipeline, options)` to use `perform(request)`. Binding retains those exact options, including correlation and identity; it does not refresh or resolve them at runtime. Prefer a short-lived bound facade when context is per-call, not a singleton holding request-scoped data. An explicit per-call options argument overrides the binding. The default Spring beans are **unbound**: short calls throw `IllegalStateException` before execution rather than silently choosing security context. ### Blocking, interruption, and limits * Each call uses `runBlocking` on the caller thread and occupies that thread until completion. No application scope or executor is used for offloading. This is an opt-in imperative entry point, including conventional MVC service calls or scheduler workers; generated Arc HTTP endpoints keep their existing coroutine hosting. Size caller capacity accordingly. * Do not call these facades from coroutines, event loops, Arc handlers, or Arc bounded application work. Use `CommandPipeline`/`QueryPipeline` from Kotlin, or the existing async facades from Java. Marked Arc bounded work and reentrant blocking-facade work fail fast with `IllegalStateException`, including after `withContext` dispatcher migration. The safety marker restores pooled-thread state; it is not request context. It cannot detect every unrelated external coroutine or work detached into a fresh context. There are no thread-name heuristics. * Returned `CommandResult` and `QueryResult` are unchanged: inspect `isSuccess()` and the validation/authorization/exception fields. A failed result is not automatically thrown. Exceptions propagated by the underlying pipeline and ordinary handler cancellation retain their exact failure identity rather than being wrapped by the facade, except for the interruption policy below. * Interrupting a blocked caller cancels the invocation, waits for cooperative structured cleanup, restores the caller’s interrupt flag, then throws unchecked `CancellationException` with `InterruptedException` as its cause. This includes synchronous handlers, performers, and validators whose interruptible waits throw before the default pipelines could turn them into failure results. Begun command scopes complete in reverse order with a failed result; interrupted validation stops before subsequent validators or the handler/performer. An already-interrupted caller does not start execution. Repeated interrupts do not detach cleanup. * **Normalization policy:** any directly observed `InterruptedException` at framework-managed callback boundaries during this blocking invocation means cancellation, including an exception propagated from a worker through `withContext`. The facade sets the **caller** interrupt flag even in that worker-originated case; this is not evidence that the caller was physically interrupted. The cause is the exception observed at the framework boundary (coroutine stack recovery may already have copied an upstream exception). Shared model/concept traversal also recognizes interruption through its existing reflection/stage wrappers; there is no arbitrary cause-chain search. Application-swallowed interruption is undetectable, including replacement pipelines that consume it themselves. The first framework-observed interruption also takes precedence over an earlier ordinary cancellation retained during command-scope cleanup; every begun scope still completes. This policy is not enabled for ordinary suspending/async pipeline callers or by the bounded-scope safety marker alone. Ordinary handler cancellation without interruption does not set the flag. * Existing `CompletionStage.await` cancels the stage when it also implements `Future`; cancellation cannot force arbitrary external work to stop or undo a side effect. `Source`’s `BlockingRealPipelineInterruptionTest` covers real synchronous and staged command/query execution, command/query/model validation, cleanup/rollback, worker normalization, and unchanged legacy and guard-only coroutine behavior. `BlockingPipelineNormalizationTest` covers callback exception sites, completion-time interruption, reflective traversal, and invocation-marker restoration. * There is no facade timeout. The application owns its deadline and interrupting caller-task policy; a caller timeout is not a promise that cleanup or external work has already stopped. Cleanup must cooperate, so an uncooperative handler or cleanup can keep the caller blocked. Existing pipeline scope-completion timeouts still apply. Use the async API when retaining a blocked thread is wrong. Continue with [in-process Java testing](/arc/backend/kotlin/guides/testing/) or compare the [Kotlin tutorial](/arc/backend/kotlin/get-started/). # Ambient tenancy > Propagate the current tenant through coroutines, across dispatcher switches, and into blocking Java call paths using Arc's coroutine context element. ## How Arc carries the tenant through coroutine work Arc carries the current `TenantId` in a `TenantCoroutineContext` coroutine context element rather than a `ThreadLocal`. A `ThreadLocal` silently loses its value when a coroutine is resumed on a different thread after a suspension point or a dispatcher switch; a coroutine context element propagates automatically to every child coroutine and is restored correctly across every dispatcher switch. This mirrors the .NET `AsyncLocal` used by the reference implementation: both flow with their respective async execution models and are invisible across boundaries that do not explicitly propagate their context. ## Establish a tenant scope Use `withTenant` to install a `TenantId` for the current coroutine and its children. Java callers running on a plain thread use `TenantContextBridge`, which is covered below: * Java ```java void handleRequest(TenantId tenantId) { // Callable form — returns a value String result = TenantContextBridge.withTenant(tenantId, () -> { TenantId current = TenantContextBridge.currentTenant(); return doWork(current); }); // Runnable form — no return value TenantContextBridge.withTenant(tenantId, () -> record(TenantContextBridge.currentTenant()) ); // TenantContextBridge.currentTenant() returns null here — scope is restored on exit } ``` * Kotlin ```kotlin suspend fun handleRequest() { withTenant(TenantId("acme")) { // currentTenant() returns TenantId("acme") here performWork() } // currentTenant() returns null here — scope is restored on exit } ``` The tenant is visible from any child coroutine, including those launched with `async` or `launch`, and survives switching dispatchers: ```kotlin withTenant(TenantId("acme")) { val result = withContext(Dispatchers.Default) { currentTenant() // still TenantId("acme") after the dispatcher switch } } ``` Nested `withTenant` calls shadow the outer tenant for the duration of the inner block: * Java ```java void nestedScopes() { TenantContextBridge.withTenant(new TenantId("outer"), () -> { TenantContextBridge.withTenant(new TenantId("inner"), () -> use(TenantContextBridge.currentTenant()) // TenantId("inner") ); use(TenantContextBridge.currentTenant()); // TenantId("outer") — restored }); } ``` * Kotlin ```kotlin suspend fun nestedScopes() { withTenant(TenantId("outer")) { withTenant(TenantId("inner")) { currentTenant() // TenantId("inner") } currentTenant() // TenantId("outer") — restored } } ``` ## Read the current tenant in Kotlin Two suspend functions read the ambient tenant: * `currentTenant()` — returns the `TenantId` from the nearest enclosing `withTenant` scope, or `null` when no tenant is active. Prefer this in code that must distinguish “no tenant configured” from “default tenant explicitly selected”; callers that require a tenant should throw explicitly rather than proceeding with a default. * `currentTenantOrNotSet()` — returns `TenantId.NOT_SET` when no tenant is active, for infrastructure that uses that sentinel value. ## Bridge for blocking Java call paths Java callers that run on a plain thread without coroutine infrastructure use `TenantContextBridge`, shown in the Java tabs above. `TenantContextBridge.currentTenant()` reads from a `ThreadLocal` that is kept in sync with the coroutine context element for the duration of the blocking scope. It returns `null` outside any `withTenant` scope. Do not call `TenantContextBridge.currentTenant()` from within a coroutine — use the suspend `currentTenant()` instead. `TenantContextBridge.withTenant` uses `runBlocking` internally and blocks the calling thread for the duration of the body. Do not call it from within an active coroutine — use the suspend `withTenant` instead. ## This is ambient propagation only These APIs carry an already-resolved `TenantId` through coroutine and blocking-Java work. They do not resolve a tenant from HTTP headers, JWT claims, subdomains, or query parameters. Tenant resolution from inbound requests is the responsibility of the `TenantIdResolver` chain; the resolved `TenantId` is what gets passed to `withTenant` or to Arc’s command and query execution options. Separately again: [development tenant discovery](/arc/backend/kotlin/guides/development-users-and-tenants/) with `TenantsProvider` answers “which tenants exist” for tools such as [Lens](/tools/lens/), not “which tenant is this request for”. # Integrate commands and read models with Chronicle > Append transactional event responses, apply concurrency scopes, resolve read models, execute reactor side effects, and test commands in process. ## Add Chronicle optionally `io.cratis:arc` has no Chronicle dependency. Add the optional integration when commands should append returned events or use Chronicle read models and reactors. **`io.cratis:cratis` is the preferred, single dependency for an event-sourced Cratis application** - it is a pure aggregator over Arc, its Spring Boot wiring, and the Chronicle integration, so a consumer never has to assemble the three by hand: ```kotlin dependencies { implementation("io.cratis:cratis:") } ``` Reach for `io.cratis:arc-chronicle-spring-boot-starter` directly only when a consumer deliberately wants Arc’s Chronicle wiring without whatever `io.cratis:cratis` bundles alongside it in the future - the two artifacts stay at the same version and are otherwise equivalent today: ```kotlin dependencies { implementation("io.cratis:arc-chronicle-spring-boot-starter:") } ``` Either starter transitively includes `io.cratis:chronicle-spring-boot-starter` and follows its Spring Boot bean backoff conventions. The current integration depends on Chronicle.Kotlin 6.10.0; the compatibility gate below runs it against kernel 19.22.2. The Chronicle client checks the kernel contracts during connection and refuses incompatible servers. Configure `cratis.chronicle.event-store`; Chronicle then supplies the conventional client and `IEventStore` beans while Arc adds tenant-aware event-store resolution, a staged command scope, response handlers, read-model adapters, and a reactor command-side-effect helper. ## Run the complete Kotlin and Java samples The optional `Samples:Kotlin:ChronicleSpringBoot` and `Samples:Java:ChronicleSpringBoot` applications use only public starters and generated model-bound endpoints. Each includes: * `CreateTask`, whose returned `TaskCreated` event is appended and omitted from the client response; * `RenameTask`, which receives the tenant-local `TaskView`, records its previous title, and applies the caller’s exact observed event-log position; * a Chronicle reducer that materializes `TaskView` from event context without duplicating the event-source ID in event payloads; * generated GET and RFC QUERY endpoints at `/api/tasks/by-id` and `/api/tasks`; * strict generated TypeScript contracts for Kotlin and Java. Run either sample against a compatible Chronicle 19.22.2 kernel and include `x-cratis-tenant-id` on every request. `Samples/run.sh --chronicle` starts the digest-pinned `cratis/chronicle:19.22.2-development` image for you. The explicit compatibility gate starts its own pinned development image and exercises both applications: ```shell ./gradlew :ContractTests:chronicleRealKernelTest --no-configuration-cache ``` Override `-PchronicleKernelImage=` only for a deliberate compatibility run. The default image is `cratis/chronicle:19.22.2-development` pinned to digest `sha256:83e33a9ac7b5ded1b31801083498782d370e5b4ac86b7cc2447165b08e48057f`. Docker absence fails this explicit task; normal unit tests do not start a kernel. ## Return an event A plain Chronicle event response needs a stable command key backed by `String`, `UUID`, a number, or an Arc/Chronicle concept wrapping one of those values. * Java ```java @EventType public record TaskCreated(String title) { } @Command public record CreateTask(@CommandKey String id, String title) { public TaskCreated handle() { return new TaskCreated(title); } } ``` * Kotlin ```kotlin @EventType data class TaskCreated(val title: String) @Command data class CreateTask(@CommandKey val id: String, val title: String) { fun handle(): TaskCreated = TaskCreated(title) } ``` Without a usable `@CommandKey`, Arc returns an error validation result with `reason: "rule"` and `reasonDetail: "commandKey"`; it does not guess an event-source identifier. The command pipeline captures that key before validation and execution. Returned plain events use `CommandContext.commandKey`, not a second call to the provider after the handler may have mutated state. Mixed responses preserve the explicit identifiers of routed events. A manually constructed context must capture a `CommandKeyProvider` value through its constructor or explicitly supply a handler-resolved `commandKey`; a null key fails closed rather than falling back to the handler registry. ## Declare command event defaults Declare optional event metadata on the command when its returned events share stable Chronicle routing or compliance defaults: * Java ```java @Command @CommandEventSourceType("Task") @CommandEventStreamType("Tasks") @CommandEventStreamId("active") @CommandEventSubject("task-owner") public record CreateTask(@CommandKey String id, String title) { public TaskCreated handle() { return new TaskCreated(title); } } ``` * Kotlin ```kotlin @Command @CommandEventSourceType("Task") @CommandEventStreamType("Tasks") @CommandEventStreamId("active") @CommandEventSubject("task-owner") data class CreateTask(@CommandKey val id: String, val title: String) { fun handle(): TaskCreated = TaskCreated(title) } ``` KSP emits these values as typed `CommandEventMetadata`, and the command pipeline adds them to `CommandContext` before filters and execution scopes run. Immediate appends, staged transactions, and `EventsWithConcurrencyScopes` use them as defaults. Java applies the same annotations to its command class; a manual handler can use `CommandDescriptor.withEventMetadata`. Implement `CommandEventStreamIdProvider` or `CommandEventSubjectProvider` when a value depends on the command instance. Both are ordinary method-shaped interfaces for Kotlin and Java. Do not combine a provider with the matching static annotation; KSP reports `ARCKSP0110` because one metadata slot cannot have two sources. Omit a declaration to keep Chronicle’s own fallback: source and stream types use `Default`, while stream ID and subject use the event-source identifier. A present value must be nonblank and contain no control characters; KSP reports `ARCKSP0110` otherwise. Explicit routed metadata wins. `EventForEventSourceId` keeps any source type, stream type, stream ID, or subject it already carries, and only missing values inherit the command declaration. Arc appends command causation without replacing the wrapper’s existing causation. Return a non-empty collection or array of Chronicle `@EventType` values to target the command-key stream. Return `EventForEventSourceId` values to route events explicitly. A collection or array may mix both forms: plain events use the command key, routed wrappers retain their explicit event-source identifier, and Arc appends the complete ordered collection atomically through the cross-stream path. A mixed response containing any value that is not an `@EventType` event—or a wrapper whose event lacks `@EventType`—fails before anything is appended. `CommandResponseValues`, Kotlin `Pair`/`Triple`, and `ArcOneOf` may combine supported response values in declaration order. Per-source concurrency scopes remain explicit: use `EventsWithConcurrencyScopes` when a response needs them. That typed response contains routed events only, so give every event an event-source identifier; a bare event in a mixed collection cannot carry a concurrency scope. ## Commit one staged Chronicle unit of work The Spring integration starts one `ChronicleCommandTransaction` for the outermost command-execution root before filters. Nested commands executed in the same structured coroutine, or with `CommandExecutionOptions.nested(parentContext)`, receive distinct frame tokens but enroll in that same root. Child completion never appends. If any child or the root fails or is canceled—even when an outer handler ignores the child result—the root becomes rollback-only and discards every staged event. A successful root commits the selected event store’s ordered events with exactly one `appendMany` call and the root correlation identifier. One root may use only one event-store object and namespace; attempts to switch stores, join with another correlation/namespace, enroll after sealing, or handle transactionally without active state fail closed. The public response-handler constructors without a transaction remain explicitly nontransactional and may append immediately. The pinned Chronicle client accepts only one causation chain per batch. Arc reuses one generated command link across separate enrollments from the same execution frame. Explicit wrapper lineage and nested-command frames remain distinct: heterogeneous chains are rejected before the gRPC append, not silently flattened or split into non-atomic batches. Supporting multiple command lineages in one batch remains a separate integration/protocol gap. The SDK preflight is exercised by `ChronicleStagedCausationTests`; these tests mock the transport and do not certify a real kernel. There are no nested savepoints. Arc seals the root before any execution scope completes, rejects late joins, and fails when an unawaited child remains live. Cancellation first supplies a failed cleanup result to every begun scope, performs reverse cleanup, and only then rethrows the original cancellation. Chronicle begins before the opt-in JPA and MongoDB scopes. Reverse completion is MongoDB, JPA, then Chronicle, so a local completion failure prevents Chronicle append. This is a commit barrier, not a distributed transaction: MongoDB may commit before JPA fails, and both local stores may commit before Chronicle fails or has an indeterminate external outcome. Another Chronicle store or external system is never part of this batch. Constraint violations become validation results with reason `constraintViolation`; the constraint ID is `reasonDetail`, Chronicle details are retained as state, and a `propertyName` detail becomes a camel-cased member. Concurrency failures use `concurrencyViolation` and include expected and actual sequence numbers. Other append errors become command exception messages. ## Attach exact concurrency scopes Use `EventsWithConcurrencyScopes` when a cross-source batch needs explicit Chronicle concurrency rules: * Java ```java public EventsWithConcurrencyScopes handle() { return EventsWithConcurrencyScopes.builder() .event("account-42", new FundsWithdrawn(100)) .event("ledger-2025", new LedgerEntryAdded("account-42", 100)) .concurrencyScope("account-42", ConcurrencyScopeBuilder::withEventSourceId) .build(); } ``` * Kotlin ```kotlin fun handle(): EventsWithConcurrencyScopes = eventsWithConcurrencyScopes { event("account-42", FundsWithdrawn(100)) event("ledger-2025", LedgerEntryAdded("account-42", 100)) concurrencyScope("account-42") { withEventSourceId() } } ``` The builder requires at least one event, validates event-source labels, preserves declaration order, rejects conflicting scopes for the same label, and supplies the exact scope map to Chronicle’s atomic append. Java uses `EventsWithConcurrencyScopes.builder()`, fluent `event(...)`, and either a concrete `ConcurrencyScope` or the `Consumer` overload. ## Resolve tenant event stores The command or query context carries the tenant namespace captured by the host. For a non-null namespace, the integration resolves an event store through `TenantEventStoreResolver` and verifies that the returned store has that exact namespace. Register a `TenantEventStoreProvider` bean to supply tenant stores; the auto-configured resolver falls back to the default `IEventStore` only when its namespace matches. A null tenant uses the default store. A missing, mismatched, or failing tenant store fails closed without appending or releasing data. An application-provided resolver replaces the default composition. ## Use Chronicle read models When `ChronicleOptions` lists read-model artifacts, the integration declares those exact types to Arc’s `ReadModelForCommandResolverRegistry`. A generated command handler can then request a current read model as an unannotated parameter: * Java ```java @Command public record Withdraw(@CommandKey String accountId, int amount) { public FundsWithdrawn handle(AccountBalance balance) { if (balance.available() < amount) { throw new IllegalArgumentException("Insufficient funds."); } return new FundsWithdrawn(amount); } } ``` * Kotlin ```kotlin @Command data class Withdraw( @CommandKey val accountId: String, val amount: Int ) { fun handle(balance: AccountBalance): FundsWithdrawn { require(balance.available >= amount) { "Insufficient funds." } return FundsWithdrawn(amount) } } ``` Resolution uses the generated command key and the captured tenant store. A value produced by `provide` still has precedence. Once the ownership registry claims a read-model type, it resolves before ordinary Spring services so a model bean cannot bypass tenant/key selection. A missing model leaves the dependency unresolved; an invalid key or unavailable/mismatched store fails the command rather than falling through to another store. Chronicle claims declared ownership only for its discovered types, so Arc can arbitrate them against custom or Spring Data resolvers deterministically. The same artifact list configures `ChronicleReadModelInterceptor`. Before one-shot or observable query data leaves the pipeline, each matching read-model value is passed through Chronicle’s `readModels.release(...)` operation for compliance-protected data. Collection items are intercepted individually, and the tenant store must match the captured query namespace. ## Name read model collections When Arc’s MongoDB integration is present, its `NamingPolicy` also names the collection Chronicle projects each read model into, so a projected `Author` lands in `Authors`, the collection Arc reads by default. The Chronicle integration adapts the single `NamingPolicy` bean into a Chronicle `ReadModelNamingPolicy` bean unless the application declares one, and the Chronicle starter applies it to the `ChronicleOptions` it builds. See [read-model naming](/arc/backend/kotlin/guides/read-model-naming/#chronicle-projections-use-the-same-names). ## Execute reactor command side effects `ChronicleCommandSideEffectHandler` explicitly hands one command or a non-empty nested command aggregate from a Chronicle reactor to Arc’s real `CommandPipeline`. It accepts `CommandResponseValues`, `ArcOneOf`, Kotlin tuples, iterables, and arrays only when every leaf is a registered command. Commands run sequentially in declaration order and stop at the first failed result. These reactor side-effect calls are independent top-level command executions; earlier commands remain durable if a later one fails. `executeAsync(...)` exposes the same contract as a Java `CompletionStage`. Pass the reactor type and Chronicle `EventContext` to the handler. By default, Arc preserves the causing identity and correlation/namespace context but grants no roles, including when the causing identity is Chronicle’s system identity. Annotate a reactor type with `@ExecuteCommandsAsSystem(roles = [...])` only when the automation deliberately needs those exact roles; blank roles are rejected. This helper is an explicit side-effect boundary. It does not make every value returned by a reactor an Arc command and does not bypass command validation or authorization. ## Test without a Chronicle kernel Add `io.cratis:arc-testing` in the test configuration alongside the Chronicle integration. The Chronicle module registers a `CommandScenarioExtender` through `ServiceLoader`, so `CommandScenario` receives an in-memory event log automatically: * Java ```java void registersACustomer() { CommandScenario configured = new CommandScenario<>(module, RegisterCustomer.class); ChronicleCommandScenario chronicle = ChronicleCommandScenarios.chronicle(configured); chronicle.given().events(customerId, new CustomerRegistered("Existing")); try (BlockingCommandScenario scenario = new BlockingCommandScenario<>(configured)) { scenario.execute(new RegisterCustomer(customerId, "Ada")).shouldSucceed(); } chronicle.shouldHaveAppendedEvent(customerId, CustomerRegistered.class); } ``` * Kotlin ```kotlin suspend fun registersACustomer() { val scenario = CommandScenario(module, RegisterCustomer::class.java) scenario.givenChronicle() .events(customerId, CustomerRegistered("Existing")) val result = scenario.execute(RegisterCustomer(customerId, "Ada")) result.shouldSucceed() scenario.chronicle() .shouldHaveAppendedEvent(customerId, CustomerRegistered::class.java) } ``` Given events establish ordered history and are excluded from appended-event assertions. Builders can arrange deterministic constraint and concurrency violations; assertions inspect the same machine-readable `ValidationResult` contract used at runtime. Java uses the generated `ChronicleCommandScenarios` static bridge. See [Test commands and queries in process](/arc/backend/kotlin/guides/testing/) for the complete Kotlin and Java examples. A command that injects a Chronicle read model does not need the read-model service either: pin the model to a known state with `withReadModel` or `withReadModelForKey` on the scenario. The pin claims declared ownership through Arc’s own resolver registry rather than substituting a Chronicle service, so it works the same way for a Chronicle, JPA, or MongoDB-owned read model. See [Pin a command-side read model](/arc/backend/kotlin/guides/testing/#pin-a-command-side-read-model). # Identify a command with a key > Declare which property identifies a command, so Arc can resolve the state it acts on and route its events. Most commands act on something that already exists — a specific order, a specific account. Arc needs to know which one, before your handler runs, so that it can resolve the current state the command operates on and so that integrations can route what the command produces. You tell it by marking the identifying property with `@CommandKey`. * Java ```java @Command public record ChangeShippingAddress(@CommandKey OrderId orderId, Address address) { public void handle(@FromServices OrderRepository orders) { orders.updateAddress(orderId, address); } } ``` * Kotlin ```kotlin @Command data class ChangeShippingAddress( @CommandKey val orderId: OrderId, val address: Address ) { suspend fun handle(@FromServices orders: OrderRepository) { orders.updateAddress(orderId, address) } } ``` A command without a key is perfectly valid — a command that creates something has nothing to identify yet. ## Exactly one key `@CommandKey` may appear on **one** property. Two of them is [`ARCKSP0106`](/arc/backend/kotlin/reference/diagnostics/), a compile error, because Arc would otherwise have to guess which identity the command means and would guess consistently wrongly for one of them. The annotation is accepted on a property, a field, a value parameter or a property getter, which covers Kotlin data classes, Kotlin classes with explicit properties, and Java records alike. ## Computing a key instead of declaring one When the key is not a single declared property — it is derived from two of them, or comes from somewhere else on the command — implement `CommandKeyProvider`: * Java ```java @Command public record ArchiveTenantData(String tenant, int year) implements CommandKeyProvider { @Override public Object commandKey() { return tenant + "/" + year; } public void handle(@FromServices ArchiveService archive) { archive.run(tenant, year); } } ``` * Kotlin ```kotlin @Command class ArchiveTenantData(val tenant: String, val year: Int) : CommandKeyProvider { override fun commandKey(): Any = "$tenant/$year" suspend fun handle(@FromServices archive: ArchiveService) { archive.run(tenant, year) } } ``` `CommandKeyProvider` is a `fun interface` with a single `commandKey()` method returning `Any?`, so a Java record or class implements it without ceremony. From Kotlin you can also read it as a property: `command.key`. Return `null` to say this command has no key, which is the same as not declaring one. ## What the key is used for The key identifies the command instance. It is not a route parameter and not a validation concern — the value is not checked for existence, and declaring a key does not by itself load anything. Where it matters is in integrations that need to know *what* the command is about. The Chronicle integration uses it to decide the event source a command’s events belong to; a command that returns events without a resolvable key is rejected at that point rather than appending to the wrong stream. ## Relationship to the C# implementation The C# implementation spells the same two mechanisms `[Key]` and `ICanProvideKeyForCommand.GetKey()`. The concept, the “exactly one” rule and the role in resolving command state are the same. The types differ: C#‘s `GetKey()` returns a string, while the JVM `CommandKeyProvider.commandKey()` returns `Any?`, so each integration decides which key types it accepts. The Chronicle integration accepts `String`, `UUID`, a number, or a concept wrapping one of those. The [parity reference](/arc/backend/kotlin/reference/parity/) tracks the exact status. # Create and validate commands > Define model-bound Arc commands, resolve Spring services, validate requests, and use asynchronous handlers. ## Define a command Annotate a Kotlin class (including a data class) or Java record with `@Command`. Add a public instance method named `handle`. Kotlin’s implicit-public visibility is supported for classes, methods, and properties; spelling `public` is optional. KSP generates a reflection-free `CommandHandler`; do not create a controller or handler class. See [Kotlin visibility](/arc/backend/kotlin/reference/annotations/#kotlin-visibility) for exclusions and upgrade effects. These annotated command/query generation entry points need no `@IgnoreAutoRegistration` opt-out. This does not mean all discovery is annotation-only: KSP also traverses reachable types and inspects command-like declarations for diagnostics. A handler may return `Unit`/`void`, a response value, a Kotlin suspend result, or a Java `CompletionStage`. Unannotated `handle` parameters are resolved from Spring. Java `Optional` parameters first consume a matching provided value, then preserve owned read-model absence as `Optional.empty()`, and otherwise resolve an ordinary Spring `T` service; raw, wildcard, nested, nullable, and Kotlin-authored `Optional` signatures are rejected. The generated adapter executes inside Arc’s bounded application coroutine scope; it does not use `GlobalScope` or `ThreadLocal` request state. `CommandHandlerArgumentResolver` and its public suspending resolution methods are generated-invocation SPIs: they must be public because generated handlers are emitted into consumer packages. Ordinary Java code invokes the generated `CommandHandler` through Arc’s command pipeline, including Java `CompletionStage` handlers; it is not expected to call Kotlin suspend resolver methods directly. * Java ```java @Command @AllowAnonymous public record CreateTask(String title) { public TaskCreated handle(TaskRepository repository) { var task = repository.create(title); return new TaskCreated(task.id(), task.title()); } } ``` * Kotlin ```kotlin @Command @AllowAnonymous data class CreateTask(val title: String) { suspend fun handle(repository: TaskRepository): TaskCreated { val task = repository.create(title) return TaskCreated(task.id, task.title) } } ``` ## Declare body state explicitly KSP keeps public primary-constructor properties in constructor order, then appends remaining public declared Kotlin properties in name order without duplicates. A public backed body `var`, backed `val`, or `var` with a private setter participates in the generated command contract: Arc’s Jackson 3 mapper accepts supplied JSON values for all three. Body initializers are server behavior, not client default literals or evidence that a property is optional. Field/getter validation constraints, `@Valid`, documentation summaries, and a body `@CommandKey` use the same metadata path as constructor properties. A constructor key plus a body key fails with `ARCKSP0106` rather than selecting one silently. Keep server-only state nonpublic or annotate the member with `@field:JsonIgnore` or `@get:JsonIgnore`. Ignored properties do not enter this metadata graph; `@JsonIgnore(false)` does not exclude a member. Computed getter-only state can be serialized but cannot be advertised as writable command input. KSP reports source-located `ARCKSP0300` for computed or explicitly `READ_ONLY` Kotlin properties reached through a command input graph, including Java record commands and nested models. Use a backed property, ignore the member, or return a separate output model. Output-only computed getters remain supported. Use default Arc wire names and symmetric access (`AUTO` or explicit `READ_WRITE`) for backed body input properties. Explicit `READ_WRITE` is also supported on backed output-model properties. Body `@JsonProperty` renames, write-only access, and split ignore/explicit-property declarations that the shared descriptor cannot represent fail with `ARCKSP0300`; there are no new manifest access flags. This is not general support for arbitrary application Jackson naming strategies, mixins, or custom serializers. Model inheritance retains separate base links and declared overrides; this change does not flatten or establish inherited command state, keys, or constraints. Declare command state directly on the command. Previously omitted public body state now adds generated fields, validation, reachable models, and potentially key behavior. Review regenerated clients and resolve newly reported unsupported input shapes; this is a source-contract correction, not a manifest-version change. See [body-property proxy metadata](/arc/backend/kotlin/guides/typescript-proxies/#include-declared-body-properties). ## Prepare handler values Add an optional public instance `provide` method when `handle` needs fetched or computed data. It runs after authorization and validation, but before `handle`; the `/validate` route never invokes it. Its parameters resolve from the current Spring scope. KSP generates direct calls for regular, suspend, and `CompletionStage` methods. * Java ```java @Command public record CompleteTask(TaskId taskId) { public Task provide(Tasks tasks) { return tasks.get(taskId); } public TaskCompleted handle(Task task, AuditLog audit) { audit.record(task.id()); return new TaskCompleted(task.id()); } } ``` * Kotlin ```kotlin @Command data class CompleteTask(val taskId: TaskId) { suspend fun provide(tasks: Tasks): Task = tasks.get(taskId) fun handle(task: Task, audit: AuditLog): TaskCompleted { audit.record(task.id) return TaskCompleted(task.id) } } ``` Return one value, a `Pair`, `Triple`, `CommandProvidedValues`, or an `ArcOneOf` alternative. Provided values are matched to `handle` parameters in declaration order and each match is consumed once; unmatched parameters fall back to Spring. A returned `CommandResult`, `ValidationResult`, non-empty validation-result iterable/array, or `AuthorizationResult` is a control signal and can short-circuit the handler. Kotlin handlers can use `commandProvidedValuesOf(first, second)` and `commandResponseValuesOf(first, second)` for explicit ordered aggregates. Java callers retain the `CommandProvidedValues.of(...)` and `CommandResponseValues.of(...)` factories and builders. `ArcOneOf` supplies runtime alternatives; it is not a `@GenerateOneOf` union generator. Union generation is not planned. ## Return aggregate responses A command response can combine server-consumed values with one value sent to the client. Arc recursively processes typed `Pair` and `Triple` members, the selected value inside `ArcOneOf`, and nested `CommandResult` values by merging their result state and processing a present successful response. After built-in values and values declared by `@HandlesCommandResponseValues` are classified as handled, a client-bearing response must leave exactly one client leaf. A handled-only response may leave none. Two or more possible client leaves are ambiguous and KSP stops compilation with `ARCKSP0109`. Declare custom handled types on the response handler. The declaration is static metadata; `canHandle` remains the runtime decision, and the handler must also be registered with the command pipeline. In Spring Boot, register a `CommandResponseValueHandler` bean. If a value was statically classified as handled but no registered handler accepts it, Arc fails closed with an unsuccessful `CommandResult` instead of exposing that value as the client response. * Java ```java record AuditEntry(String orderId) {} record OrderReceipt(String orderId) {} @Command public record CreateOrder(String orderId) { public Pair handle() { return new Pair<>(new AuditEntry(orderId), new OrderReceipt(orderId)); } } @HandlesCommandResponseValues({AuditEntry.class}) final class AuditEntryResponseHandler implements BlockingCommandResponseValueHandler { @Override public boolean canHandle(CommandContext context, Object value) { return value instanceof AuditEntry; } @Override public CommandResult handle(CommandContext context, Object value) { return CommandResult.success(context.getCorrelationId()); } } @Configuration(proxyBeanMethods = false) class ResponseHandlerConfiguration { @Bean CommandResponseValueHandler auditEntryResponseValueHandler() { return new BlockingCommandResponseValueHandlerAdapter(new AuditEntryResponseHandler()); } } ``` * Kotlin ```kotlin data class AuditEntry(val orderId: String) data class OrderReceipt(val orderId: String) @Command data class CreateOrder(val orderId: String) { fun handle(): Pair = AuditEntry(orderId) to OrderReceipt(orderId) } @Component @HandlesCommandResponseValues(AuditEntry::class) class AuditEntryResponseHandler : CommandResponseValueHandler { override fun canHandle(context: CommandContext, value: Any): Boolean = value is AuditEntry override suspend fun handle(context: CommandContext, value: Any): CommandResult<*> { if (value !is AuditEntry) { return CommandResult.error(context.correlationId, "Unsupported response value.") } // Persist value with an injected service in a real handler. return CommandResult.success(context.correlationId) } } ``` Java handlers use ordinary annotation array syntax and the blocking or asynchronous adapters. Expose the adapter as a Spring bean; annotating the Java handler declares its handled values but does not register it. Use `AsyncCommandResponseValueHandler` with `AsyncCommandResponseValueHandlerAdapter` when the Java handler returns `CompletionStage>`. The Java-callable `CommandResult.success(...)`, `invalid(...)`, `unauthorized(...)`, `error(...)`, and `exception(...)` factories create handler outcomes without Kotlin-only syntax. KSP discovers source-visible `@HandlesCommandResponseValues` declarations and dependency declarations exported by Arc KSP in a separate format-1 resource. The Arc Gradle plugin supplies the dependency index automatically; manual KSP builds must [wire the extraction task and argument provider](/arc/backend/kotlin/reference/configuration/#dependency-response-handler-metadata). A binary annotation alone, without that resource and index, still does not classify the returned type. This compiler metadata never registers a runtime handler. Source-visible declarations include handlers generated by another KSP processor in a later round of the same compilation. Arc finalizes response classification after discovery, so earlier commands use those declarations too. A finally handled response type is not a client-model root by itself; it remains in the graph when independently reachable elsewhere, such as through a command input or query model. This does not register the runtime handler or change `canHandle`; runtime registration is still required. `CommandResponseValues` stores an erased `List` and is therefore intentionally untyped to KSP: its contents can be processed dynamically at runtime but cannot supply a client response type for generated TypeScript or OpenAPI. Prefer typed `Pair`, `Triple`, `ArcOneOf`, and `CommandResult` shapes when generated response metadata is required. These rules document Arc.Kotlin’s current JVM contract and do not claim aggregate-response parity with Arc on .NET. ## Add validation For constructor-authored rules shared with generated TypeScript, follow the [fluent validation walkthrough](/arc/backend/kotlin/guides/validation/), including [direct validation, preflight and execution](/arc/backend/kotlin/guides/validation/#execute-and-inspect-rejection). Generated modules register these rules automatically, including supported nested models. Ordinary imperative validators and Jakarta remain separate; neither is executed by build tooling. Register a Spring bean implementing `CommandValidator`. Java implements `BlockingCommandValidator` and registers it through `BlockingCommandValidatorAdapter`, because Arc collects only `CommandValidator` beans — a bean implementing the blocking interface on its own is never run. Return immutable validation results; an error rejects execution. `POST /validate` runs the same command pipeline without invoking the handler. * Java ```java public class CreateTaskValidator implements BlockingCommandValidator { @Override public Class getCommandType() { return CreateTask.class; } @Override public List validate(CreateTask command, CommandContext context) { if (command.title().isBlank()) { return List.of(ValidationResult.error("A task title is required.", List.of("title"))); } return List.of(); } } @Configuration class ValidationConfiguration { @Bean CommandValidator createTaskValidator() { return new BlockingCommandValidatorAdapter<>(new CreateTaskValidator()); } } ``` * Kotlin ```kotlin @Component class CreateTaskValidator : CommandValidator { override val commandType = CreateTask::class.java override suspend fun validate(command: CreateTask, context: CommandContext): List = if (command.title.isBlank()) { listOf(ValidationResult.error("A task title is required.", listOf("title"))) } else { emptyList() } } ``` `ValidationResult.information`, `ValidationResult.warning`, and `ValidationResult.error` are static factories that name the severity and default members, state, reason, and reason detail. They read the same from Java, and the `ValidationResult` constructor with an explicit `ValidationResultSeverity` remains available. Without an explicit threshold, only errors block execution. Use `@TreatWarningsAsErrors` to set `Information` as the maximum nonblocking severity, so warnings and errors block. Callers can set `X-Allowed-Severity` to a severity name or wire number; feedback numerically above that threshold blocks, and invalid header values produce `malformedRequest`. When Spring has a Jakarta `Validator` bean, the starter automatically adds a command filter. It validates the command and nested `@Valid` object, array, iterable, and map values, maps paths such as `items[0].name` into Arc members, terminates safely on cyclic graphs, and returns ordinary `rule` validation results before `provide` or `handle` runs. Use `ConceptValidator` for a reusable Arc invariant on a `ConceptAs` type. `DefaultCommandValidationFilter` and `DefaultQueryValidationFilter` accept concept validators and walk reachable object, record, collection, array, and map values without requiring each owner to repeat the rule. Matching uses `conceptType.isInstance`, including subtypes; failures are attached to the owning member path. The default Spring filter beans auto-discover `CommandValidator`, `QueryValidator`, and `ConceptValidator` beans in Spring order (`Ordered`, class `@Order`, or factory-method `@Order`). Register concept validators as ordinary Kotlin or Java beans; application-supplied default filter beans remain authoritative. Concept rules run independently of Jakarta `@Valid` and do not require a Jakarta `Validator` bean. These imperative rules are server-only: registering a bean does not invent TypeScript validation or OpenAPI constraints. Directly constructing `ArcAutoConfiguration` outside Spring retains the original typed-validator-only factory behavior; construct the Core default filters with explicit concept validator lists for standalone use. Cancellation from a concept validator, Kotlin property getter, or Java record accessor propagates through command execution/validation, one-shot queries, and observable opening; the Java async facades cancel their futures. It is not `validatorFailed` feedback and is never silently skipped. Other validator runtime failures still produce safe `validatorFailed` feedback. Ordinary unreadable Kotlin properties remain skipped, while ordinary Java record accessor failures remain exception results. A cancelled nested command marks its root rollback-only even when the caller catches the cancellation. ## Reuse model validation Register `ModelValidator` for a server-only rule reused by command roots, nested models, and supplied query arguments. Its `modelType: Class` matches the **exact runtime class**, not subclasses or interfaces. `validate(model, context)` is suspending; Java implementations use `BlockingModelValidator` or `AsyncModelValidator` with `BlockingModelValidatorAdapter` or `AsyncModelValidatorAdapter` from `io.cratis.arc.java`. Publish the adapter as a `ModelValidator` Spring bean, not a second Java-specific discovery SPI. The asynchronous adapter reuses Arc’s nonblocking, cancellable `CompletionStage` await bridge; neither adapter creates an executor. `ModelValidationContext` has constructors accepting either `(CommandContext, memberPath)` or `(QueryContext, memberPath)`. Exactly one of `commandContext` and `queryContext` is present. Read `correlationId`, `principal`, `tenantId`, `tenantNamespace`, and `serviceResolver` from this immutable operation view; do not retain it beyond validation. Services, provided command values, and operation context objects are not traversal roots. Only the command instance or each supplied query argument is walked; an omitted Kotlin default has no pre-invocation value and is not manufactured for validation. Typed `CommandValidator` and `QueryValidator` rules run once before the shared model/concept traversal. A separately registered model rule for the command class is a distinct rule and runs once at the root. Nodes run parent-before-child, model rules before concept rules. Unmatched owners are still traversed. Records use declaration order, Kotlin public properties use alphabetical order, and public-field fallback uses alphabetical order; containers preserve encounter order. Identity tracking prevents cycles and repeated visits within one root. Each query argument is an independent root. Model rules apply to explicitly registered exact scalar, enum, array, and collection runtime classes **before** terminal traversal checks; platform scalars and concepts are not reflected into afterward. Concept matching remains assignable. Model feedback members are relative to the current node. An empty member list or empty member attaches to the node path; ordinary members gain a dotted prefix, and a leading bracket attaches directly (`items` plus `[0]` becomes `items[0]`). Unlike concept feedback, model members named `value` or `rawValue` are not collapsed. Severity, state, reason, and reason detail are preserved. State is not deep-copied: every feedback field is client-visible application data and must not contain secrets. An empty returned list means no feedback. Arc validates and snapshots the entire returned list before merging it, rejecting a Java null list, null element, or wrong-type element without retaining partial feedback. Ordinary exceptions from model validation or list extraction become safe node-scoped `validatorFailed` feedback, not exception-message text. Cancellation, including known reflection/stage-wrapped cancellation, propagates; fatal `Error` instances are not swallowed. A model rule throwing a `ValidationFailure` exception still follows this validator-failure policy, not command exception conversion. Spring collects model beans with its ordered provider, preserving class, `Ordered`, and factory-method ordering, candidate eligibility, and application filter overrides. The original Core filter constructors and Spring public factory signatures remain available. For standalone use, supply the required three iterables `(typedValidators, conceptValidators, modelValidators)` to either default validation filter. Direct concept exclusions use the additive four-iterable constructor described below; model rules themselves cannot be excluded. These imperative rules do not add KSP metadata, TypeScript validation, or OpenAPI constraints, and do not establish Arc .NET parity. ## Exclude a direct concept rule edge Register immutable `ConceptValidationExclusion(ownerType, member)` values from `io.cratis.arc.validation`. Both Kotlin and Java use the ordinary constructor; Java reads `getOwnerType()` and `getMember()`. Spring collects eligible exclusion beans with its ordered provider when it installs the default filters. Standalone filters accept four required iterables `(typedValidators, conceptValidators, modelValidators, exclusions)` and snapshot the registrations; all previous constructors remain available. Application-supplied default filter beans remain authoritative. The owner matches the **exact runtime class**. The member must be one direct public Kotlin property, Java record component, or public instance field whose **declared type** implements `ConceptAs`. Inherited readable members are supported when registered against the runtime owner: a registration against a base class does not match a derived instance. JavaBean methods without a corresponding supported property or field are not graph members. Construction rejects blank, unknown, dotted, indexed, wildcard, private, and non-concept members without constructing an owner or executing user getters. A scalar root, a collection element, and a path to a nested member cannot be excluded; register the nested owner type and its direct member instead. Only concept rules — `ConceptValidator` and `FundamentalsConceptValidator` — on that edge are suppressed. Owner rules, other exact `ModelValidator` rules (including plain model rules for the concept itself), Jakarta constraints and `@Valid` cascading, root typed validators, and other pipeline filters still run. An excluded edge does not suppress a sibling or another owner. Both concept-rule kinds share the actually validated concept identity, tracked separately from model/traversal identities: when an ignored edge reaches a shared concept first, a later required edge still validates it and receives its path. Required-first order validates it once, and distinct equal instances validate independently. Model rules run once per identity at their first path even if that edge excludes concept rules. Cycles still terminate; each supplied query argument has independent identity tracking. Exclusions are server-only registrations, not annotations or client rules. They do not independently change the manifest format, generated Jakarta metadata, TypeScript constraints, or OpenAPI schemas. The separate [IgnoreValidation annotation](/arc/backend/kotlin/reference/validation/#ignore-a-validation-member-edge) has a broader whole-edge contract and explicit format-8 metadata. Generated Kotlin and Java scenario/HTTP checks cover the concept/model boundary; HTTP query model checks install an application string-to-model converter rather than claiming built-in arbitrary object binding. The Spring hosting checks separately use its real Jakarta provider to prove excluded edges still reject constraint violations. Neither establishes Arc .NET parity. ## Convert application exceptions to command validation Implement `io.cratis.arc.validation.ValidationFailure` on an application exception to supply command validation feedback instead of exception details. Kotlin implements `val validationResults: List`; Java implements `List getValidationResults()`. There is no required framework exception superclass. `CommandResult.fromException(correlationId, exception)` is callable as a static factory from Java and from the Kotlin companion. The legacy `CommandResult.exception(...)` still always creates an ordinary exception result. * Java ```java public final class ApplicationFailure extends RuntimeException implements ValidationFailure { private final List validationResults; public ApplicationFailure(List validationResults) { super("Internal diagnostic"); this.validationResults = validationResults; } @Override public List getValidationResults() { return validationResults; } } ``` * Kotlin ```kotlin class ApplicationFailure( override val validationResults: List ) : RuntimeException("Internal diagnostic"), ValidationFailure ``` Supply a nonempty list containing only nonnull validation results. Conversion takes an immutable list snapshot and preserves each result’s message, members, severity, reason, reason detail, and state. It does not synthesize validation from the exception message, and a valid payload carries no exception message or stack trace. **All payload fields, including `state` and `reasonDetail`, are client-visible application data, even in production. Never put secrets in them.** State objects are retained, not deep-copied or automatically redacted. Empty, null, or malformed Java payloads and ordinary exceptions (including checked exceptions such as `IOException`) while reading or iterating them fall back to the original ordinary exception result, never success. Partial feedback is discarded if extraction fails. Cancellation takes precedence over the marker, including cancellation thrown by the payload getter or iterator. Fatal `Error` instances propagate rather than being treated as malformed payloads. The factory does not search causes or suppressed exceptions; the existing `CompletionStage` adapter still unwraps its asynchronous failure at the invocation boundary. The default command pipeline uses this conversion at its existing ordinary exception boundaries: context providers, filters, scope begin, preparation (`provide`), invocation (`handle`), response matching/handling, scope completion, and validate-only context/filter execution. Specialized mappings remain authoritative: missing owned dependencies still produce `dependencyUnavailable`, and exceptions already handled inside default typed/concept/model validators still follow their existing `validatorFailed` policy. This is command-only support, not query exception conversion or a general exception registry. Severity filtering stays at its existing stages rather than becoming a uniform exception policy: | Failure origin | Payload severity handling | | --- | --- | | Direct `fromException` factory | Retains every severity. | | Context providers, scope begin, thrown preparation/invocation, scope completion | Retains every severity; even warning-only feedback rejects the result. | | Filters and response matching/handling | Existing stage threshold removes nonblocking feedback; an entirely filtered payload can leave a successful result. | | Validate-only | Context failures retain every severity; filter failures use the existing threshold. No preparation, invocation, or scopes run. | A failed nested execution marks its root rollback-only even when ignored. Cancellation during payload extraction inside an ordinary exception catch is remembered as failure bookkeeping: only successfully begun scopes complete, once in reverse order, before cancellation is rethrown. Existing noncancellable cleanup and independent completion timeouts remain in effect. A pre-scope context failure has nothing to complete; validate-only calls do not enlist in or poison an executing root. Previously accumulated authorization, validation, and ordinary error fragments remain in failed results, and rejected responses are not exposed. See the [HTTP contract](/arc/backend/kotlin/reference/http-contract/#http-statuses) for validation 400, ordinary 500, and mixed-result precedence. This JVM-native capability does not establish Arc .NET parity. ## Protect the command Use `@AllowAnonymous`, `@Authorize(policy = "...")`, `@Authorize(roles = ["..."])`, or repeatable `@Roles`. Arc captures the servlet or Spring Security principal at request entry before suspending. Authorization can be declared on the artifact class or on its operation — the command `handle` function or a read-model query function. **The operation wins.** When the operation declares any `@Authorize` or `@Roles`, only its policy, roles, and schemes apply and the class declaration is ignored entirely; the class declaration applies only to operations that declare none. An operation therefore replaces the class rule rather than adding to it, and it can admit callers the class rule would reject: on a class requiring `admin`, a `@Roles("auditor")` operation is reachable by auditors only, never by admins. Repeated `@Roles` on the *same* target still combine, and a caller satisfies a role list by holding any one of its roles. Applications can register ordered `AuthenticationHandler` beans, or Java `AsyncAuthenticationHandler` beans returning `CompletionStage`. Both handler APIs participate in one Spring-ordered chain before protected Arc endpoints. The first handler returning a principal or a failure decides the outcome; failures are terminal and later handlers do not run. Only anonymous results allow the next handler to try. Unauthorized responses use a generic body without exposing failure details. Kotlin consumers can exhaustively `when` on `AuthenticationResult.outcome`, whose variants are `AuthenticationOutcome.Authenticated`, `Failed`, and `Anonymous`. The client-readable `.cratis-identity` cache cookie is deliberately excluded from authentication input. Spring Security remains optional. When present, Arc can map its captured `Authentication`; without it, the servlet principal is used. An application-supplied Arc `Authentication` service replaces the default handler chain. ## Use Kotlin and Java Core conveniences Kotlin extensions keep application code concise without changing the Java ABI. `ServiceResolver.resolve()` and `require()` provide reified service lookup; `commandProvidedValuesOf(...)` and `commandResponseValuesOf(...)` preserve ordered aggregate values; and `CommandResult` supports `fold`, `getOrThrow`, `onSuccess`, and `validationOrNull`. These extensions are `@JvmSynthetic` where a Kotlin function shape would be awkward from Java. Java consumers can implement ordinary blocking or `CompletionStage` contracts and wrap them with the adapters in `io.cratis.arc.java`. Command adapters cover filters, authorization filters, validators, execution scopes, response-value handlers, manual handlers, and authorization policies. `JavaAsyncScope` owns a structured coroutine scope over a caller-supplied executor and creates cancellable `CompletionStage` command, query, authentication, observable-query, and query-health facades; closing it cancels its operations and only shuts down an executor explicitly transferred with `owningExecutorService`. ## Handle context provider failures For both `execute` and `validate`, the default pipeline builds context values in provider registration order, resolves the command key, then resolves the dynamic event-stream ID and subject. An ordinary exception stops context construction immediately and returns an unsuccessful `CommandResult` with the supplied correlation identifier. Later providers, filters, preparation, and the handler do not run; no execution scope begins or completes for that failed frame. A failed nested `execute` marks its root execution rollback-only even when the caller ignores the child result. Provider cancellation remains `CancellationException` (a cancelled `CompletionStage` through `JavaAsyncScope`), and an ignored nested execution cancellation also marks the root rollback-only. `validate` never creates an execution frame or enlists scopes, so an ignored validation result or caught validation cancellation does not itself mark an active root rollback-only. Closed execution tokens and mismatched root, correlation, or tenant-namespace joins remain programmer errors rather than provider error results. For ordinary failures without a usable `ValidationFailure` payload, Core retains exception messages and stack traces for host logging regardless of `exposeExceptionDetails`. Hosts must apply the existing exception-detail redaction policy before serializing a production response; see the [HTTP contract reference](/arc/backend/kotlin/reference/http-contract/). ## Call the endpoint Commands accept `POST` with a JSON body. Conventional routes use the configured prefix, package segments, and kebab-cased command name. The response is always a `CommandResult` JSON envelope. Kotlin callers can use `fold`, `getOrThrow`, `onSuccess`, and `validationOrNull` without changing that envelope or its Java API. See the [HTTP contract reference](/arc/backend/kotlin/reference/http-contract/) for status and header rules. # Development users and tenants > Implement TenantsProvider and UsersProvider so development tools such as Lens can discover and switch between tenants and users on a running Arc application. ## Why this exists `GET /.cratis/tenants` and `GET /.cratis/users` are development-only discovery endpoints. They have nothing to do with [tenant resolution](/arc/backend/kotlin/guides/ambient-tenancy/) at request time — they exist purely so a tool can ask your running application “which tenants and users do you know about right now?”, sourced from wherever your application already keeps that list, instead of a hand-maintained copy pasted into the tool. The primary consumer is [Lens](/tools/lens/), the Cratis browser extension for exercising a running Arc application during development. Lens reads these two endpoints to populate its tenant and user pickers, then injects the corresponding tenant and identity headers into every request the inspected page makes while a selection is active — no restart, no hand-crafted headers. See [Lens: where the tenant and user roster comes from](/tools/lens/#where-the-tenant-and-user-roster-comes-from) for the extension side of this contract, with screenshots against seeded demo data. Arc’s own authentication layer treats both endpoints as anonymous — see [Know which built-in routes are anonymous](/arc/backend/kotlin/guides/security/#know-which-built-in-routes-are-anonymous) — including in production. A Spring Security chain, such as the default one the platform identity bridge adds, can still require authentication for them. Do not rely on that: scope development-only providers out of production builds or restrict the paths at trusted ingress. Without any provider registered, both endpoints return an empty JSON array rather than failing. ## Implement a tenants provider Register a `TenantsProvider` bean. It is a Kotlin `fun interface`, so a lambda is enough: ```kotlin import io.cratis.arc.tenancy.Tenant import io.cratis.arc.tenancy.TenantId import io.cratis.arc.tenancy.TenantName import io.cratis.arc.tenancy.TenantsProvider import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration class DevelopmentTenants { @Bean fun developmentTenantsProvider() = TenantsProvider { listOf( Tenant(TenantId.of("acme-corp"), TenantName("ACME Corporation")), Tenant(TenantId.of("widget-inc"), TenantName("Widget Inc")) ) } } ``` `provide()` is a suspend function, so it can call a repository or a remote service directly. `GET /.cratis/tenants` returns each `Tenant` as `{"id": "...", "name": "..."}`. Java registers an `AsyncTenantsProvider` instead, returning a `CompletionStage>`: ```java import io.cratis.arc.tenancy.AsyncTenantsProvider; import io.cratis.arc.tenancy.Tenant; import io.cratis.arc.tenancy.TenantId; import io.cratis.arc.tenancy.TenantName; import java.util.List; import java.util.concurrent.CompletableFuture; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class DevelopmentTenants { @Bean public AsyncTenantsProvider developmentTenantsProvider() { return () -> CompletableFuture.completedFuture(List.of( new Tenant(TenantId.of("acme-corp"), new TenantName("ACME Corporation")), new Tenant(TenantId.of("widget-inc"), new TenantName("Widget Inc")) )); } } ``` ## Implement a users provider `UsersProvider` (Kotlin) and `AsyncUsersProvider` (Java) follow the same shape and return `User`, which pairs a host-neutral `ArcPrincipal` with optional application-specific `details`: ```kotlin import io.cratis.arc.authorization.ArcPrincipal import io.cratis.arc.identity.User import io.cratis.arc.identity.UsersProvider import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration class DevelopmentUsers { @Bean fun developmentUsersProvider() = UsersProvider { listOf( User( ArcPrincipal( name = "alice@contoso.com", isAuthenticated = true, roles = setOf("admin", "developer"), authenticationScheme = "aad" ), mapOf("department" to "Engineering") ), User( ArcPrincipal( name = "bob@contoso.com", isAuthenticated = true, roles = setOf("tester"), authenticationScheme = "aad" ), mapOf("department" to "QA") ) ) } } ``` `GET /.cratis/users` returns each entry as `{"principal": {"id": "...", ...}, "details": {...}}`. ## Multiple providers compose Register more than one `TenantsProvider` or `UsersProvider` — Arc aggregates the coroutine providers in `@Order` sequence, then the Java async providers in `@Order` sequence, and keeps the first entry seen for each tenant or user identifier. A coroutine provider therefore wins over a Java async provider for the same identifier, whatever their `@Order` values, so one source can supply fixtures while another reads from a database or an external service without either implementation knowing about the other. ## See also * [Lens](/tools/lens/) — the browser extension this page’s providers feed, with screenshots against seeded demo data. * [C#: implementing a tenants provider](/arc/backend/csharp/identity/development-and-topologies/#implementing-a-tenants-provider) — the same discovery contract on ASP.NET Core. * [Tenancy](/arc/backend/kotlin/guides/ambient-tenancy/) — request-time tenant resolution, which this page’s providers are deliberately separate from. * [Authenticate and authorize Arc endpoints](/arc/backend/kotlin/guides/security/) — the anonymous-route list these endpoints belong to, and `GET /.cratis/me`. # Bracket command execution with a scope > Wrap every command in a lifetime concern such as a transaction, completing in reverse order whatever the outcome. A pipeline filter runs *around* one concern. An execution scope brackets the whole command: it begins before any filter or handler runs, and completes after the outcome is known, whether that outcome is success, a validation rejection, a thrown exception or cancellation. That makes it the right place for a lifetime concern — a transaction, a unit of work, a diagnostic span — rather than something you open in `handle` and hope to close. Arc’s own Chronicle integration is built this way: `ChronicleCommandTransaction` is an execution scope, which is why an event transaction commits or rolls back around the command rather than inside it. ## The contract * Java ```java public interface BlockingCommandExecutionScope { void begin(CommandContext context); CommandResult complete(CommandContext context, CommandResult result); } public interface AsyncCommandExecutionScope { void begin(CommandContext context); CompletionStage> complete(CommandContext context, CommandResult result); } ``` * Kotlin ```kotlin public interface CommandExecutionScope { fun begin(context: CommandContext) suspend fun complete(context: CommandContext, result: CommandResult<*>): CommandResult<*>? } ``` `begin` is synchronous and runs before filters and the handler. `complete` runs for **every post-begin outcome**, in **reverse begin order**, so scopes nest like a stack. It may return an immutable fragment to amend the final result — returning `null` leaves the result unchanged. Only scopes that actually began are completed. ## Register a scope Declare it as a bean. Arc collects every `CommandExecutionScope` in the context. * Java ```java public class UnitOfWorkScope implements BlockingCommandExecutionScope { private final UnitOfWork unitOfWork; public UnitOfWorkScope(UnitOfWork unitOfWork) { this.unitOfWork = unitOfWork; } @Override public void begin(CommandContext context) { unitOfWork.begin(); } @Override public CommandResult complete(CommandContext context, CommandResult result) { if (result.isSuccess()) { unitOfWork.commit(); } else { unitOfWork.rollback(); } return null; } } @Configuration class ScopeConfiguration { @Bean CommandExecutionScope unitOfWorkScope(UnitOfWork unitOfWork) { return new BlockingCommandExecutionScopeAdapter(new UnitOfWorkScope(unitOfWork)); } } ``` * Kotlin ```kotlin @Component class UnitOfWorkScope(private val unitOfWork: UnitOfWork) : CommandExecutionScope { override fun begin(context: CommandContext) { unitOfWork.begin() } override suspend fun complete(context: CommandContext, result: CommandResult<*>): CommandResult<*>? { if (result.isSuccess) { unitOfWork.commit() } else { unitOfWork.rollback() } return null } } ``` Returning `null` is the common case: the scope did its work and has nothing to say about the response. ## Completion is bounded `complete` runs under a timeout — `executionScopeCompletionTimeout`, five seconds by default, and it must be positive. A scope that hangs cannot hold the request open indefinitely. This matters for what you put in `complete`. A commit is a reasonable thing to await; a long retry loop or an unbounded network call is not, because exceeding the timeout fails the completion rather than extending it. ## From Java The Java tab above implements `BlockingCommandExecutionScope` and registers it through its adapter, because Arc collects only `CommandExecutionScope` beans. Java implements one of two interfaces, depending on whether the completion work is asynchronous, and registers it through the matching adapter: * `AsyncCommandExecutionScope` — `complete` returns `CompletionStage>`, registered via `AsyncCommandExecutionScopeAdapter`. * `BlockingCommandExecutionScope` — `complete` returns `CommandResult` directly, registered via `BlockingCommandExecutionScopeAdapter`. The blocking variant is called inline on Arc’s bounded request worker pool and occupies one of its workers for as long as it runs, the same as a [blocking filter](/arc/backend/kotlin/guides/pipeline-filters/). Keep `complete` short, or implement the asynchronous variant for work that waits on I/O. ## When not to use a scope A scope runs for **every** executed command, and it begins before the filters, so it also completes when a filter rejects the command. Preflight `validate` calls do not run scopes, and neither do failures that happen before scopes begin, such as a missing handler or an exception while building the command context. If the concern belongs to one command or one group of them, a [pipeline filter](/arc/backend/kotlin/guides/pipeline-filters/) is the narrower tool and says so in its own declaration. A scope is also not a compensation mechanism. `complete` observes the outcome and can amend the result; it does not re-run the handler or undo work the handler did directly against a service. # Use the generated client from React > Consume generated Arc commands and queries from a React application, including observable subscriptions, conditional queries, and identity. Generating a TypeScript client is only half the promise. The other half is that the client is pleasant to use — that a command is a class you construct and execute, that a live query is a hook, and that neither one makes you write a URL, a request body, or a response type by hand. This guide is about that half. [TypeScript proxies](/arc/backend/kotlin/guides/typescript-proxies/) covers producing the files; everything here assumes you already have them. ## Get the proxies into your application Generation writes into the directory you point it at: ```kotlin cratisArc { proxies { outputDirectory.set(file("../frontend/src/generated")) } } ``` Treat that directory as build output. Do not edit it, do not commit it, and regenerate it whenever the backend changes — a generated client that is one build behind the server it talks to is worse than no generated client, because it compiles. Install the runtime packages the generated code imports: ```shell npm install @cratis/arc @cratis/arc.react @cratis/fundamentals ``` ## Wrap the application once Everything below needs an `Arc` context. It carries the transport choice, the base path, and the callback that supplies request headers: ```tsx import { Arc } from '@cratis/arc.react'; createRoot(document.getElementById('root')!).render( ); ``` That is the whole setup for a single-origin application. If your page is served from somewhere other than the backend — a Vite dev server, typically — read [Serving the page from a dev server](#serving-the-page-from-a-dev-server) before you go further, because one thing does need saying out loud. ## Execute a command A generated command is a class with a `use()` hook. The hook hands back the command instance and a setter for its properties: ```tsx import { CreateTask } from './generated/CreateTask'; export const CreateTaskForm = () => { const [command, setValues] = CreateTask.use(); const create = async () => { const result = await command.execute(); if (result.isSuccess) { setValues({ title: '' }); } }; return ( <> setValues({ title: event.target.value })} /> ); }; ``` `execute()` returns the same envelope the HTTP endpoint returns, already typed: `isSuccess`, `response`, `validationResults`, `isAuthorized`. A rejected command is not an exception — it is a result you render: ```tsx const result = await command.execute(); if (!result.isSuccess) { setErrors(result.validationResults.map(validation => validation.message)); } ``` Each validation result carries the `members` it applies to, so you can put the message next to the field that caused it rather than in a banner at the top of the form. `command.validate()` runs the same validation without the side effect, which is what you want on blur rather than on submit. ## Read with a query A query method that returns a plain value or a list generates a one-shot query. `use()` performs it and re-performs it when its arguments change: ```tsx import { ById } from './generated/features/queryshowcase/ById'; const [result] = ById.use({ id }); if (result.isPerforming) return

Loading…

; return

{result.data.name}

; ``` `result.hasData` tells you whether anything arrived, `result.isAuthorized` whether the caller was allowed, and `result.data` is typed from the read model. ## Subscribe to a live query A query method returning a `Flow` — or a `Flow.Publisher` in Java — generates an observable query. The hook looks almost identical, and that is the point: ```tsx import { All } from './generated/features/changestream/All'; const [result] = All.use(); ``` There is no polling loop, no refetch after a mutation, no cache to invalidate. A command that changes the underlying data causes a push, and every component subscribed to that query re-renders. A change made by another browser tab, or by `curl`, arrives the same way. Several components calling `All.use()` share one subscription through the query instance cache, so ten panels on a page cost one connection and one push, not ten. ### React to what changed When you care about the delta rather than the whole collection, `useChangeStream` reports it: ```tsx const changes = All.useChangeStream(item => item.id); // changes.added, changes.replaced, changes.removed ``` The key selector is what makes identity meaningful — without it Arc can only compare whole items. ## Hold a query back until it makes sense React hooks cannot be called conditionally, which is awkward when a query needs an argument the page does not have yet. `when(condition)` is the way out: ```tsx const [result] = ById.when(selectedId !== undefined).use({ id: selectedId }); ``` While the condition is false, the hook sends no request and starts no subscription of its own. It is not a request whose result is thrown away. The hook can still create a query instance and use the shared query cache, and a subscription another component already holds stays open. The shared [conditional queries](/arc/frontend/react/queries/conditional-queries/) guide covers the exact rules. Use it for the query that needs a selection, and for the query that needs a signed-in user: ```tsx const identity = useIdentity(); const [result] = Authenticated.when(identity.isSet).use(); ``` ## Know who is signed in `useIdentity()` reads what `/.cratis/me` returned — the identity your [`IdentityDetailsProvider`](/arc/backend/kotlin/guides/security/) produced, typed: ```tsx import { useIdentity } from '@cratis/arc.react/identity'; const identity = useIdentity(); identity.isSet; // resolved and authenticated identity.name; identity.isInRole('administrator'); identity.details; // typed as YourIdentityDetails ``` A bare `useIdentity()` types `details` as `object`. The generic argument only changes the TypeScript type. To hydrate complex values such as a `Guid` into real instances, declare the details class with `@field` decorators and pass it once as ``. The shared [identity guide](/arc/frontend/react/identity/) describes both steps. ## Serving the page from a dev server In development the page usually comes from Vite on one port while Arc runs on another. Proxy Arc’s two path prefixes so the browser still sees a single origin: ```ts export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', ws: true }, '/.cratis': { target: 'http://localhost:8080', ws: true } } } }); ``` `ws: true` matters on both: observable queries upgrade to a WebSocket under `/.cratis`. The backend registers those WebSocket routes only when Spring WebSocket is on its classpath, as described in [Consume an observable query](/arc/backend/kotlin/guides/queries/#consume-an-observable-query). Name the dev-server origin on the backend Spring refuses a cross-origin WebSocket handshake with `403`, and a browser reports that as a socket that never opens — no error, no status, just a subscription that stays at “connecting” forever. Because the page comes from the dev server’s origin, every handshake is cross-origin. Tell the backend to accept it: ```properties cratis.arc.observable-queries.allowed-origins=http://localhost:5173 ``` Leave it empty in a deployment. A WebSocket handshake is not subject to the same-origin policy that `fetch` obeys, so this check is what stops another site from opening a socket with your visitor’s cookies attached. ## Authenticating a subscription A one-shot query can carry credentials in a header, because `fetch` can set headers. `EventSource` and the WebSocket handshake cannot. If your authentication reads a header only, every subscription will quietly drop to anonymous while ordinary requests keep working — which is a confusing way to find out. Make sure whatever produces your `ArcPrincipal` reads a cookie as well as a header. The [sample’s `ArcPrincipalFactory`](https://github.com/Cratis/Arc.Kotlin/blob/main/Samples/Kotlin/SpringBoot/src/main/kotlin/io/cratis/arc/samples/kotlin/springboot/SampleAuthentication.kt) does exactly that, and is a reasonable shape to copy. ## Changing who you are Credentials are captured when a query instance is created, and an observable subscription is authorized once, at handshake. Changing the signed-in user therefore has to reach further than the next request: remount the `Arc` context and drop the shared connection, or you will keep sending the previous principal. The [sample frontend](https://github.com/Cratis/Arc.Kotlin/blob/main/Samples/Frontend/src/main.tsx) shows the whole shape — a `key` on the `Arc` element, `IdentityProvider.clearIdentityCookie()`, and `resetSharedMultiplexer()`. ## See it running Every pattern on this page is exercised by a runnable application: ```shell ./Samples/run.sh ``` It starts the Kotlin backend, regenerates the proxies from it, and opens a React frontend against them. `--language java` runs the same frontend against the Java backend unchanged. See [the samples](https://github.com/Cratis/Arc.Kotlin/blob/main/Samples/README.md) for the full list of pages and what each one demonstrates. # Observe Arc execution > The starter activates when the application provides a Micrometer ObservationRegistry. Spring Boot Actuator supplies one in a typical metrics or tracing… Add the Arc observability starter alongside the Arc Spring Boot starter: ```kotlin dependencies { implementation("io.cratis:arc-observability-spring-boot-starter:") } ``` The starter activates when the application provides a Micrometer `ObservationRegistry`. Spring Boot Actuator supplies one in a typical metrics or tracing application. Without a registry, with `ObservationRegistry.NOOP`, or when `cratis.arc.observability.enabled=false`, Arc leaves the existing pipeline beans unchanged. ## Recorded operations The starter decorates application-supplied or Arc-provided contracts without replacing their implementations: | Observation | Operations | Low-cardinality tags | | --- | --- | --- | | `arc.command` | `execute`, `validate` | `arc.operation`, `arc.artifact`, `arc.outcome` | | `arc.query` | `perform` | `arc.operation`, `arc.query`, `arc.outcome` | | `arc.observable.query` | `open`, `subscription`, `emission` | `arc.operation`, `arc.query`, `arc.outcome` | | `arc.authentication` | `authenticate` | `arc.operation`, `arc.outcome` | | `arc.identity.details` | `provide` | `arc.operation`, `arc.outcome` | Outcome values are bounded categories such as `success`, `invalid`, `unauthorized`, `not_ready`, `authenticated`, `anonymous`, `rejected`, `cancelled`, and `error`. Thrown failures are attached to the observation so a tracing handler can mark the span as failed. Observable subscriptions remain active until completion, cancellation, or failure; each delivered result has a separate emission observation. Arc never records command values, query arguments, tenant identifiers, user identifiers, claims, headers, or cookies. Command artifact types and generated query names are the only model-specific low-cardinality dimensions. ## Correlation context The correlation identifier itself is established by the Arc Spring Boot starter, not by this one. Its host-wide correlation filter resolves one identifier per request, publishes it to every route in the host, and places it in MDC under `arc.correlation_id` for the duration of the servlet filter chain. See the [HTTP contract reference](/arc/backend/kotlin/reference/http-contract/) for that contract. For command and query operations, Arc places the existing request correlation identifier in the observation context under `arc.correlation_id`. It is deliberately not a metric or span tag. When SLF4J is available, the starter also places the value in MDC under `arc.correlation_id` for the duration of each coroutine resume, which is where the servlet-thread binding cannot reach. When the OpenTelemetry API is available, it places the same value in baggage. Both contexts are restored after execution, including cancellation and failure. This preserves structured coroutine propagation rather than relying on an unmanaged application `ThreadLocal`. OpenTelemetry is optional. Add the tracing implementation appropriate for the application, for example Spring Boot Actuator with Micrometer’s OpenTelemetry bridge. The Arc starter does not select an exporter. ## Configuration ```yaml cratis: arc: observability: enabled: true correlation-baggage-enabled: true correlation-logging-enabled: true ``` The properties are ordinary JavaBean configuration through `ArcObservabilityProperties`. Disable baggage or logging correlation independently when the host owns those contexts itself. # Declare observable queries > Return Kotlin Flow, JDK Flow.Publisher, or RxJava 3 Observable from a query method and choose between full snapshots and delta streams. ## Choose a return type An observable query is a static method on a `@ReadModel` companion object (Kotlin) or a Java static method that returns a streaming type. Arc recognises three families: * Kotlin `kotlinx.coroutines.flow.Flow` or `Flow>` * JDK `java.util.concurrent.Flow.Publisher` or `Publisher>` * RxJava 3 `Observable`, `ObservableSource`, or `Subject` — requires the optional `arc-rxjava3` artifact at runtime (see [RxJava 3 option](#rxjava-3-option)) Arc generates an `OBSERVABLE` performer for any of these. One-shot returns (`T`, `List`, or `Page`) stay request-response. ## Return a stream Declare the observable query as a static method — on the companion object with `@JvmStatic` in Kotlin, as a `static` method in Java — so generated performers can reach it. Kotlin returns a `Flow`; Java returns a `java.util.concurrent.Flow.Publisher`, which Arc wraps with `asKotlinFlow()` before the observable pipeline: * Java ```java @ReadModel @AllowAnonymous public record TaskView(String id, String title) { public static java.util.concurrent.Flow.Publisher> all( @FromServices TaskRepository repository ) { return repository.observeAll(); } } ``` * Kotlin ```kotlin @ReadModel @AllowAnonymous data class TaskView(val id: String, val title: String) { companion object { @JvmStatic fun all(@FromServices repository: TaskRepository): Flow> = repository.observeAll() } } ``` `repository.observeAll()` may return any cold or hot stream. ## Keep state a snapshot can read A source that only emits holds nothing. A JDK `SubmissionPublisher`, for example, tells a new subscriber nothing until the next change — and that is visible on the wire, because Arc answers a snapshot `GET` from the source’s current value and, by default, reports a source without one as `202 Not Ready` rather than holding the request open. A caller can pass `waitForFirstResult=true` to wait up to the configured limit instead. Hold the state instead. Kotlin uses `MutableStateFlow`, the JVM substitute for .NET’s `ISubject`. The JDK has no equivalent, so Arc supplies `ObservableState` for Java. Neither needs an external dependency: * Java ```java @ReadModel @AllowAnonymous public record TaskView(String id, String title) { private static final ObservableState> tasks = new ObservableState<>(List.of()); public static Flow.Publisher> all() { return tasks; } } ``` * Kotlin ```kotlin private val _tasks = MutableStateFlow>(emptyList()) @ReadModel @AllowAnonymous data class TaskView(val id: String, val title: String) { companion object { @JvmStatic fun all(): Flow> = _tasks } } ``` The same holds when the state lives in a service rather than on the read model: * Java ```java @Component public final class TaskSource { private final ObservableState> tasks = new ObservableState<>(List.of()); public Flow.Publisher> observe() { return tasks; } public void publish(List updated) { tasks.set(updated); } } ``` * Kotlin ```kotlin @Component class TaskSource { private val tasks = MutableStateFlow>(emptyList()) fun observe(): Flow> = tasks fun publish(updated: List) { tasks.value = updated } } ``` `ObservableState` is a `Flow.Publisher`, so a query method returns it directly. Every subscriber sees the current value first and then each change; a subscriber that falls behind sees only the newest value rather than a backlog, which is the right behavior for state. Reach for it whenever a Java observable query should also answer a plain `GET`. ## RxJava 3 option If your application already uses RxJava 3, you may return `io.reactivex.rxjava3.core.Observable`, `ObservableSource`, or `subjects.Subject` from the same static-method shape. Add the optional runtime artifact: ```kotlin implementation("io.cratis:arc-rxjava3:0.0.0-SNAPSHOT") ``` Then declare the query exactly as you would for `Flow`: ```kotlin @ReadModel data class TaskView(val id: String, val title: String) { companion object { @JvmStatic fun all(@FromServices repository: TaskRepository): Observable> = repository.rxObserveAll() } } ``` Java static methods work the same way: ```java @ReadModel public record TaskView(String id, String title) { public static io.reactivex.rxjava3.core.Observable> all( @FromServices TaskRepository repository ) { return repository.rxObserveAll(); } } ``` **Backpressure and buffering.** `Observable` has no backpressure protocol. Arc buffers up to 64 values between the RxJava producer and the Kotlin collector; a producer that outruns the collector beyond that fails the flow with `IllegalStateException` rather than dropping values silently. Use a backpressure-aware source such as `Flowable`, or an explicit RxJava operator such as `toFlowable`, when the producer can outpace the consumer. **Subject as a push handle.** `io.reactivex.rxjava3.subjects.PublishSubject` or `BehaviorSubject` serve the same push-side role as `MutableStateFlow`, but require the `arc-rxjava3` artifact: ```kotlin private val subject = io.reactivex.rxjava3.subjects.PublishSubject.create>() companion object { @JvmStatic fun all(): io.reactivex.rxjava3.subjects.Subject> = subject } ``` ## Transfer mode: full snapshots versus deltas Add `transferMode` to the subscription body: * `full` — every emission carries the complete current snapshot with no change set. * `delta` — the first emission is a full snapshot; subsequent emissions carry only the change set. * Omitting `transferMode` keeps the legacy behaviour: every emission carries both the full snapshot and a `changeSet`, with the first one listing every item as added. For delta mode, Arc needs a stable item identity. When no identity accessor exists, or an extracted key is null or duplicated, Arc falls back to exact serialised JSON identity. That fallback reports additions and removals only — changing any field appears as one removal plus one addition, not a replacement. Use stable, unique item identities when replacements or lower serialisation cost matter. Change sets do not encode position changes. Do not rely on delta updates to reproduce list reordering. ## Subscription lifetime Each SSE or WebSocket subscription is independently authorised and isolated. The subscription remains open until the source `Flow` completes, the connection drops, or an `Unauthorized` guard terminates it. Cancelling collection from the Arc side disposes any underlying RxJava subscription or JDK `Flow` subscription. ## Deliberately rejected types `reactor.core.publisher.Flux` is rejected at compile time. Reactor is part of the Spring ecosystem, and the `Source` module and its `artifacts`/`metadata` packages must remain Spring-free — enforced by `./gradlew checkSpringBoundary`. Accepting `Flux` as a query return type would draw a Reactor compile-time dependency into that boundary. `org.reactivestreams.Publisher` is also rejected. The `org.reactivestreams` interfaces are superseded by the structurally identical JDK `java.util.concurrent.Flow` types introduced in Java 9. Use `java.util.concurrent.Flow.Publisher` instead. Use `@QueryTransport(QueryTransportType.OBSERVABLE)` only when you need to annotate a method that Arc would not otherwise recognise as observable. Annotating a non-streaming return with `OBSERVABLE` is a compile-time error. # Publish an OpenAPI document > Add the Arc OpenAPI Spring Boot starter and serve a generated OpenAPI 3.1 document. ## Add the starter Add the OpenAPI starter to a Spring Boot application that uses generated Arc artifact modules. ```kotlin dependencies { implementation("io.cratis:arc-openapi-spring-boot-starter:") } ``` The starter activates only in a servlet web application with Arc’s generated modules. It uses `swagger-models` for the public document model and does not require springdoc. ## Read the document The same cached JSON document is available from both routes: * `/v3/api-docs` * `/.cratis/openapi.json` Arc generates and serializes the OpenAPI 3.1 document once during application startup. Applications can inject `io.swagger.v3.oas.models.OpenAPI` or `ArcOpenApiDocument`; the latter also exposes a defensive copy of the cached JSON bytes. ## Understand the generated contract The document follows the routes calculated by `cratis.arc.endpoints`, including the route prefix, skipped package segments, name inclusion, namespace conflicts, and query `@Path` overrides. | Arc artifact | OpenAPI operation | | --- | --- | | Command | `POST` on the command route | | Command validation | `POST` on `/validate` | | Query | `GET` on the query route | | Identity details schema | `GET /.cratis/identity-details/schema` | | Current identity, when an identity provider exists | `GET /.cratis/me` | The optional RFC `QUERY` runtime method is deliberately omitted because OpenAPI Path Items do not define it. Generated components cover command payloads, client-supplied query parameters, model, interface, and enum metadata, nullable and collection types, and Arc’s `CommandResult`, `QueryResult`, `ValidationResult`, `PagingInfo`, and `ChangeSet` envelopes. Service, `QueryRequest`, and `QueryContext` parameters are runtime infrastructure and are omitted from OpenAPI. Enum schemas use their numeric Arc wire values and expose names through `x-enumNames`. Property keys and `required` entries use Arc’s default JSON and TypeScript naming rule: `Title` becomes `title`, while names beginning with two uppercase letters, such as `URLValue`, stay unchanged. Nullable properties are non-required and allow JSON null; Arc’s default mapper omits null properties. This describes the default Arc wire policy, not arbitrary application mapper, mixin, or annotation naming overrides. Supply an application-owned document when your wire policy differs. Generated Kotlin and Java interface descriptors receive reusable object components containing their declared metadata fields, and interface-valued properties and collection items reference those components. Simple names are shared across models, interfaces, enums, concepts, and commands; collisions and Arc’s reserved envelope names use qualified component names instead. Base-class model schemas retain `allOf` composition rather than flattening inherited properties. Interface and derived-model schemas describe only the structure represented in generated metadata: they do not invent complete `oneOf` alternatives, discriminators, or missing inheritance information. Each discovered Kotlin or Java `ConceptAs` receives a reusable component schema for its underlying wire value rather than an object wrapper. Direct and concept-backed `UUID`, `LocalDate`, `LocalTime`, and `Duration` values remain `type: string` with `format: uuid`, `format: date`, `format: time`, and `format: duration`, respectively. Arc disables Jackson’s `WRITE_DURATIONS_AS_TIMESTAMPS` in both Core and Spring, so the documented duration shape matches its ISO-8601 JSON string and generated TypeScript `string`; it is not Fundamentals `TimeSpan` because the Java and C# wire formats differ. Generated TypeScript clients use `Guid`, `DateOnly`, and `TimeOnly`, but those classes do not change the OpenAPI wire shapes. Other string and integer concepts retain their corresponding scalars; enum concepts retain the underlying numeric values and `x-enumNames`. Command and model properties, query parameters, collection items, query data, and command responses reference those scalar concept schemas. Imperative `ConceptValidator` implementations remain runtime behavior and are not translated into OpenAPI keywords. The current OpenAPI generator documents the concept wire shape, not executable concept-validation rules. Secured operations reference the `Bearer` HTTP JWT scheme. Generated authorization metadata is preserved through `x-roles`, `x-policy`, and `x-authenticationSchemes` operation extensions. ## Override document ownership Arc backs off when the application supplies an `OpenAPI` bean or an `ArcOpenApiDocument` bean. An application handler on either document route also wins because Arc’s fallback handler mapping runs after application request mappings. # Apply cross-cutting rules with pipeline filters > Write command and query filters that decide for a whole feature, in Kotlin or ordinary Java, and order them deliberately. Some rules belong to an artifact. “This command needs a title” is about that command, and it lives on that command. Other rules belong to a whole area. “Everything in the billing feature needs the billing role” is not really about any one command — it is about the feature — and writing it on each command in turn means the twenty-first command is the one somebody forgets. A filter is where that kind of rule goes: one declaration that every command or query in the pipeline passes through. ## Write a command filter A filter sees the resolved [`CommandContext`](/arc/backend/kotlin/guides/commands/) — the command instance, its type, the principal authentication already produced, the correlation identifier, the tenant — and returns a result that is merged into the outcome. An unsuccessful result stops the remaining filters. It stops the handler too when the result carries an authorization failure, an exception, or validation feedback that is still blocking after the allowed-severity threshold is applied; warning-only feedback is dropped by default and the handler runs: * Java ```java public final class BillingCommandFilter implements BlockingCommandFilter { private static final String BILLING_PACKAGE = "com.example.features.billing"; private static final String BILLING_ROLE = "billing"; @Override public CommandResult execute(CommandContext context) { if (!context.getCommandType().getName().startsWith(BILLING_PACKAGE) || context.getPrincipal().isInRole(BILLING_ROLE)) { return CommandResult.success(context.getCorrelationId()); } return CommandResult.unauthorized( context.getCorrelationId(), "Role '" + BILLING_ROLE + "' is required for commands in '" + BILLING_PACKAGE + "'." ); } } ``` * Kotlin ```kotlin public class BillingCommandFilter : CommandFilter { override suspend fun execute(context: CommandContext): CommandResult<*> { if (!context.commandType.name.startsWith(BILLING_PACKAGE)) { return CommandResult.success(context.correlationId) } if (context.principal.isInRole(BILLING_ROLE)) { return CommandResult.success(context.correlationId) } return CommandResult.unauthorized( context.correlationId, "Role '$BILLING_ROLE' is required for commands in '$BILLING_PACKAGE'." ) } private companion object { const val BILLING_PACKAGE = "com.example.features.billing" const val BILLING_ROLE = "billing" } } ``` Register it as a bean and it applies to every command: * Java ```java @Bean public CommandFilter billingCommandFilter() { return new BlockingCommandFilterAdapter(new BillingCommandFilter()); } ``` * Kotlin ```kotlin @Bean public fun billingCommandFilter(): CommandFilter = BillingCommandFilter() ``` A filter that does not apply to the command in front of it returns success and gets out of the way. That first check is what keeps a feature’s rule scoped to the feature. This example is an ordinary filter, so it runs in `@Order` sequence with the others. Because its job is to authorize, make it an `AuthorizationCommandFilter` (in Java, a `BlockingAuthorizationCommandFilter` registered through `BlockingAuthorizationCommandFilterAdapter`) when it must run before filters that cause effects. [Order them deliberately](#order-them-deliberately) explains why. ## Write a query filter The same shape, against `QueryContext`. The query’s fully qualified name is what you match on, because a query is a method rather than a type: * Java ```java public final class BillingQueryFilter implements BlockingQueryFilter { private static final String BILLING_PACKAGE = "com.example.features.billing"; private static final String BILLING_ROLE = "billing"; @Override public QueryResult execute(QueryContext context) { if (!context.getQueryName().value().startsWith(BILLING_PACKAGE) || context.getPrincipal().isInRole(BILLING_ROLE)) { return QueryResult.success(context.getCorrelationId()); } return QueryResult.unauthorized(context.getCorrelationId()); } } ``` * Kotlin ```kotlin public class BillingQueryFilter : QueryFilter { override suspend fun execute(context: QueryContext): QueryResult<*> { if (!context.queryName.value.startsWith(BILLING_PACKAGE)) { return QueryResult.success(context.correlationId) } if (context.principal.isInRole(BILLING_ROLE)) { return QueryResult.success(context.correlationId) } return QueryResult.unauthorized(context.correlationId) } private companion object { const val BILLING_PACKAGE = "com.example.features.billing" const val BILLING_ROLE = "billing" } } ``` A query filter runs for observable queries too, at subscription time — so a rule written once covers both the one-shot `GET` and the live subscription. ## In ordinary Java Java implementations never implement a suspending method. Pick the adapter that matches how your code is already written: | Your code | Implement | Register with | | --- | --- | --- | | Synchronous | `BlockingCommandFilter` / `BlockingQueryFilter` | `BlockingCommandFilterAdapter` / `BlockingQueryFilterAdapter` | | `CompletionStage` | `AsyncCommandFilter` / `AsyncQueryFilter` | `AsyncCommandFilterAdapter` / `AsyncQueryFilterAdapter` | A filter whose job is to authorize has its own variant of each interface. Its adapter keeps it in the authorization group that runs first: * `BlockingAuthorizationCommandFilter`, registered with `BlockingAuthorizationCommandFilterAdapter` * `BlockingAuthorizationQueryFilter`, registered with `BlockingAuthorizationQueryFilterAdapter` * `AsyncAuthorizationCommandFilter`, registered with `AsyncAuthorizationCommandFilterAdapter` * `AsyncAuthorizationQueryFilter`, registered with `AsyncAuthorizationQueryFilterAdapter` A blocking filter occupies a request thread for as long as it runs. Keep it to decisions it can make from the context it already has; if it needs to call something remote, use the `CompletionStage` form instead. ## Order them deliberately Filters are collected in Spring `@Order` sequence, and that order is the complete rule — there is no hidden precedence: * Java ```java @Bean @Order(10) public CommandFilter auditFilter() { return new BlockingCommandFilterAdapter(new AuditCommandFilter()); } @Bean @Order(20) public CommandFilter billingFilter() { return new BlockingCommandFilterAdapter(new BillingCommandFilter()); } ``` * Kotlin ```kotlin @Bean @Order(10) public fun auditFilter(): CommandFilter = AuditCommandFilter() @Bean @Order(20) public fun billingFilter(): CommandFilter = BillingCommandFilter() ``` Authorization filters are the one exception: a filter implementing `AuthorizationCommandFilter` or `AuthorizationQueryFilter` runs before the rest regardless of its order, so a rule that denies access is never reached after a filter that has already caused an effect. Implement that interface when your filter’s job is to authorize, and the ordinary one when it is not. ## When not to reach for a filter A filter is the wrong tool when the rule is about one artifact. `@Authorize` and `@Roles` on the command or the query say the same thing closer to the code they govern, are visible in the generated client and the OpenAPI document, and do not require a reader to go looking for a bean that might apply. See [authentication and authorization](/arc/backend/kotlin/guides/security/). Use a filter when the rule genuinely spans artifacts, and prefer matching on something structural — a package prefix — over a list of names somebody has to maintain. ## See it running The [samples](https://github.com/Cratis/Arc.Kotlin/blob/main/Samples/README.md) include a Cross-Cutting Authorization page backed by exactly this pattern, in [Kotlin](https://github.com/Cratis/Arc.Kotlin/blob/main/Samples/Kotlin/SpringBoot/src/main/kotlin/io/cratis/arc/samples/kotlin/springboot/features/crosscuttingauthorization/CrossCuttingAuthorizationFilters.kt) and in [Java](https://github.com/Cratis/Arc.Kotlin/blob/main/Samples/Java/SpringBoot/src/main/java/io/cratis/arc/samples/javaspringboot/features/crosscuttingauthorization/CrossCuttingAuthorizationFilters.java). Run `./Samples/run.sh`, open the page, and add or remove the role in the toolbar to watch both the command and the query change their answer. # Expose one-shot and observable queries > Add model-bound read-model queries with GET, RFC QUERY, HTTP snapshots, SSE, and WebSocket hosting. ## Add a query to a read model Annotate the result model with `@ReadModel`. Put query operations on its Kotlin companion object with `@JvmStatic`, or use Java static methods. Mark dependency parameters with `@FromServices`; caller arguments remain unannotated. A query may also declare exact non-null `QueryRequest` and `QueryContext` parameters in any positions. KSP generates a reflection-free `QueryPerformer` that supplies service, request, and context parameters from the execution context while preserving declaration order. * Java ```java @ReadModel @AllowAnonymous public record TaskView(String id, String title) { @Path("/api/tasks/by-id") public static TaskView byId(String id, @FromServices TaskRepository repository) { return repository.byId(id); } @Path("/api/tasks") public static List all(@FromServices TaskRepository repository) { return repository.all(); } } ``` * Kotlin ```kotlin @ReadModel @AllowAnonymous data class TaskView(val id: String, val title: String) { companion object { @JvmStatic @Path("/api/tasks/by-id") suspend fun byId(id: String, @FromServices repository: TaskRepository): TaskView? = repository.byId(id) @JvmStatic @Path("/api/tasks") fun all(@FromServices repository: TaskRepository): List = repository.all() } } ``` Java query methods may return a value or `CompletionStage`. Generated performers await asynchronous results inside Arc’s request coroutine context. KSP ignores only non-query Kotlin companion and Java static helper methods whose return does not contain the enclosing read-model shape. Custom instance methods remain invalid on an `@ReadModel` and stop compilation. Adding `@Path`, `@QueryHttpMethod`, or `@QueryTransport` explicitly declares such a method as a query, so an invalid annotated return still fails compilation. Public, non-generic, non-overloaded query requirements are unchanged. Kotlin client parameters may declare defaults. GET, QUERY, and observable subscriptions preserve omission by leaving that argument out of `QueryRequest.arguments`, so the generated performer executes the Kotlin default expression. A supplied value still goes through normal conversion and validation; explicit JSON or subscription `null` is a supplied value, not omission, and is accepted only by a nullable parameter. Metadata records only that a default exists. It never publishes the expression or invents a literal value: generated TypeScript fields and call arguments are optional, OpenAPI marks the parameter non-required, and introspection removes it from the argument schema’s `required` array. Client validation skips rule results for an omitted defaulted field but still validates supplied overrides. KSP generates direct named-argument presence branches, supports at most six defaulted client parameters (64 masks), and fails larger shapes with `ARCKSP0209` rather than using reflection. Java query parameters have no Arc default feature, and overloaded query methods remain unsupported. When Spring has a Jakarta `Validator` bean, Arc automatically validates caller-supplied query arguments before invoking either a one-shot or observable performer. It evaluates Kotlin method-parameter constraints, follows `@Valid` typed argument graphs from Kotlin or Java, preserves array/list/map member paths, deduplicates equivalent violations, and terminates cyclic graphs safely. Jakarta executable validation requires a complete positional argument array, but an omitted Kotlin default has no value until invocation. Arc therefore ignores executable-constraint results for that omitted slot rather than validating a fabricated `null`; supplied arguments and their object graphs are still validated, while the default expression and any value it creates execute at the model boundary. Jakarta executable validation requires an invocation receiver, while model-bound Java query methods are static; KSP therefore rejects constraints declared directly on static Java query parameters with `ARCKSP0301`. Put those rules in a `QueryValidator` or on an `@Valid` argument model instead of accepting a server-side validation bypass. `@FromServices`, `QueryRequest`, and `QueryContext` parameters are infrastructure-owned: GET and QUERY binders ignore payload values with those names, while observable subscription arguments reject them as non-client fields. No binder requires them as caller arguments or applies client validation to them. Host-neutral `QueryValidator`, `ConceptValidator`, and exact-runtime-class `ModelValidator` rules compose through `DefaultQueryValidationFilter`; see [reusable model validation](/arc/backend/kotlin/guides/commands/#reuse-model-validation) for ordering, paths, Java adapters, and Spring wiring. Model and concept traversal visits only supplied arguments before one-shot invocation or observable opening, not result data or every emission. Omitted defaults and infrastructure remain excluded; these imperative rules are server-only and do not introduce query exception conversion. [Direct concept exclusions](/arc/backend/kotlin/guides/commands/#exclude-a-direct-concept-rule-edge) apply within supplied argument graphs. They match the exact runtime owner and direct concept member, not the argument name or a dotted path. Root concept arguments remain validated; each supplied argument has independent identity tracking, and explicit null or an omitted default creates no concept node. Jakarta and model rules remain active on excluded edges. ## Choose a route and method [Shared fluent model arguments](/arc/backend/kotlin/guides/validation/#validate-a-supplied-query-model) use request-response RFC QUERY with explicit QUERY preference. Concrete object arguments bind from JSON and run shared rules before invocation; omission and supplied null remain distinct. Shared-model GET and observable transport are not supported and fail proxy generation rather than dropping client validation. `@Path` preserves the supplied query path exactly. Without it, Arc derives a route from `cratis.arc.endpoints` settings. One-shot queries accept GET. They also accept RFC QUERY when `enable-query-http-method` is true. Use GET for scalar values: ```bash curl -sS 'http://localhost:8080/api/tasks/by-id?id=123' ``` Use QUERY for structured arguments, paging, and sorting: ```bash curl -sS -X QUERY http://localhost:8080/api/tasks \ -H 'Content-Type: application/json' \ -d '{"arguments":{},"paging":{"page":0,"pageSize":25},"sorting":{"field":"title","direction":"ascending"}}' ``` GET reserves `page`, `pageSize`, `sortBy`, and `sortDirection`. Client argument names are matched case-insensitively for GET and QUERY; two supplied names that differ only by case are malformed. QUERY accepts only `arguments`, `paging`, and `sorting`, and rejects unknown fields. QUERY responses include `Cache-Control: no-store`. Arc accepts and emits `LocalTime` values with up to seven fractional digits for 100 ns compatibility. Deserialization rejects eight or nine fractional digits as malformed, and serialization rejects values finer than 100 ns rather than rounding or truncating them. Generated GET clients serialize `DateOnly`, `TimeOnly`, and `Guid` query arguments as scalar strings. Prefer GET when a generated query has `DateOnly` or `TimeOnly` parameters: this server binding is distinct from the pinned shared `@cratis/arc` client’s explicit QUERY-body problem, which passes their component objects to native `JSON.stringify` because they have no `toJSON()` instead of invoking the typed scalar serializer. That client limitation does not change the JVM endpoint contract. Prefer GET until upstream serialization uses the typed serializer or `toJSON()`. `Guid` is unaffected because it has `toJSON()`. ## Set proxy preferences `@QueryHttpMethod(GET|QUERY|AUTO)` controls generated proxy preference. Put it on `@ReadModel` to establish a default and override that default on individual methods. Queries returning Kotlin `Flow` / `Flow>` or JDK `Flow.Publisher` / `Publisher>` are generated as `OBSERVABLE` performers; a generated observable QUERY preference has a matching server snapshot route when QUERY is enabled. ## Consume an observable query The same generated query route supports three direct transports: * HTTP GET or enabled RFC QUERY returns `200` with the current value when the query returns a `StateFlow`, because that source already holds one. A cold `Flow` or JDK `Flow.Publisher` has nothing to serve yet and returns `202` with a not-ready `QueryResult` unless `waitForFirstResult=true` is supplied. `waitForFirstResultTimeout=` can shorten the configured wait limit. Observable QUERY uses the standard `{arguments,paging,sorting}` body and `Cache-Control: no-store`; disabling QUERY returns 405 with `Allow: GET`. * SSE is selected with `Accept: text/event-stream` and sends each result as exactly `data: {QueryResult}\n\n`. * WebSocket is selected by upgrading the query route. It needs Spring WebSocket on the application classpath, because the starter declares it only at compile time: add `org.springframework.boot:spring-boot-starter-websocket` (or `org.springframework:spring-websocket`). Set `cratis.arc.observable-queries.web-socket-enabled=false` to disable these routes; the default is `true`. If Spring WebSocket is absent or this property is `false`, the WebSocket routes, including `/.cratis/queries/ws`, are not registered and SSE remains available. Data frames use `{ "type": "Data", "data": {QueryResult} }`; `Ping` and `Pong` frames carry millisecond timestamps. Multiple subscriptions can share one physical connection through `/.cratis/queries/ws`, or through `/.cratis/queries/sse` with POST requests to `/.cratis/queries/sse/subscribe` and `/.cratis/queries/sse/unsubscribe`. These fixed transport routes permit anonymous connections while still capturing a principal when credentials succeed. Every subscription then passes through its query’s normal authorization pipeline, so a protected query terminates as `Unauthorized` without disturbing authorized subscriptions on the same connection. Hub messages use the PascalCase `Connected`, `Subscribe`, `Unsubscribe`, `QueryResult`, `Unauthorized`, `Error`, `Ping`, and `Pong` types. `Connected` advertises `keepAliveIntervalMs` and `supportsSubscriptionRevisions`; result and terminal frames echo the client’s `queryId` and safe-integer `revision`. Set `transferMode` to `full` for snapshots without a change set, or `delta` for the full first snapshot followed by change sets only. Omitting `transferMode` keeps the legacy behavior: every result carries both the full snapshot and a `changeSet`, with the first one listing every item as added. Authorization, caller arguments, principal, and tenant are captured independently for every accepted subscription. An unauthorized result is terminal. With explicit `transferMode=delta`, Arc prefers a stable generated or conventional item identity. When no identity accessor exists, or an extracted key is null or duplicated, Arc falls back to exact serialized JSON identity. The fallback reports additions and removals only: changing any field is one removal plus one addition, never a replacement. It compares set membership, matching Arc .NET, so changing only the number of identical duplicate values produces no delta. Use stable, unique item identities when replacements, duplicate counts, or lower serialization cost matter. Change sets do not encode position changes, so do not rely on delta updates to reproduce list reordering. ## Extend result processing Register `QueryRendererFor` beans to transform supported result values and paging before they leave the query pipeline. Configured renderers match the **original** performer value, run in ascending `order()`, and retain registration order for ties. Each receives the preceding stage’s `current` result. Kotlin code can read the renderer’s `type` and `order` property views, while Java implementations retain `queryType()` and `order()`. A matching application renderer chain owns its data and paging. `DefaultQueryRenderers` uses its automatic `QueryableQueryRenderer` fallback only when **no configured renderer matches** the original value. This prevents restored filtered rows, lost projections, premature provider enumeration, and overwritten provider totals. Even a matching identity/no-op renderer owns its output; automatic in-memory paging is not silently appended to that chain. To filter first and then apply standard in-memory sorting/paging, explicitly include `QueryableQueryRenderer()` after the filter in the configured chain. In Spring, register it as a bean alongside the transforming renderer and make ordering explicit; its order is zero. Arc sorts primarily by each renderer’s `order()`; Spring’s `orderedStream()` supplies the input order retained for ties, not an override of that method. To place the built-in at another order, wrap/delegate it through the existing renderer interfaces. Avoid duplicate in-memory stages. This explicit stage processes `current.data`, never the original rows, and preserves null/non-iterable current projections. Do not add another in-memory paging stage after a renderer that already owns a provider page. Return `QueryPage` from the performer or use a store-specific renderer without that additional stage so filtering and paging remain owned by the database. This is a behavioral migration for existing renderer contributions: an application renderer no longer gets implicit iterable processing before or after it. Direct callers of `QueryableQueryRenderer` must initialize `QueryRendererResult.data` with the values to process; null now means a null projection, not permission to recover the original query. Later interceptors or serialization may still enumerate the **current output**; ownership does not make an unchanged provider cursor lazy end-to-end. Original-array and performer-returned `QueryPage`/`QueryResult` dispatch boundaries are unchanged. The default in-memory sorter reads public instance properties only. It honors public Kotlin getters (including properties with private setters), Java record accessors, field-backed public JavaBean getters, and public Java instance fields. It does not bypass a getter to read private storage or expose arbitrary zero-argument methods. Kotlin visibility is retained for inherited Kotlin declarations on Java classes. Every non-null runtime row type is checked before comparisons, including singleton results; inaccessible and unknown keys use the same failure path, with no sorted payload. Getter cancellation and fatal errors propagate rather than becoming ordinary query failures. This is a visibility boundary, **not a per-field authorization policy**. A public property remains public even if JSON annotations omit it. Return a suitably restricted DTO or supply a custom renderer when a query needs a narrower sort-key allowlist. Empty/all-null results provide no runtime type to inspect. Provider-owned `QueryPage` results and the documented original-array boundary are unchanged. Register `InterceptReadModel` beans for ordered, per-model interception after rendering. Kotlin code can use the interceptor’s `type` and `order` property views; Java retains `readModelType()` and `order()`. Both renderer and interceptor chains apply to one-shot and observable results. Blocking Java stores can implement the corresponding blocking convenience interfaces; asynchronous contracts use `CompletionStage`. `QueryPaging` and `QuerySorting` are Kotlin data classes, with `UNPAGED` and `UNSORTED` reusable values available as Java static fields. Kotlin query-result consumers can use `fold`, `getOrThrow`, `onSuccess`, and `validationOrNull`. Kotlin property views expose renderer/interceptor types and order, resolver ownership, and enum wire values without changing their Java methods. Java Core extensions do not require coroutine types at implementation boundaries. `BlockingQueryFilter`/`AsyncQueryFilter`, authorization-filter, validator, and manual performer adapters bridge synchronous or `CompletionStage` code; manual performers returning JDK `Flow.Publisher` are adapted to Kotlin `Flow`. `JavaAsyncScope.observableQueries(...)` exposes a cancellable `CompletionStage` open operation and a demand-aware JDK publisher, while `queryHealth(...)` exposes health snapshots the same way. Register `GuardObservableQueryEmission` beans when authorization or another condition must be rechecked for every emission. A denied emission produces an unauthorized terminal result and cancels the subscription. A suppressed emission is withheld from the subscriber and leaves the subscription open. The guard context includes the performer, principal, tenant ID, tenant namespace, arguments, current data, and whether this would be the first emission delivered to the subscriber. Suppressed emissions are not deliveries, so `isFirstEmission` stays true until one is delivered, and `delta` transfer sends that first delivered emission as a full snapshot. ### Bound emission-guard arguments `DefaultObservableQueryEmissionGuards` captures supported argument values once at the start of each guard dispatch, then reconstructs independent values for every guard before invoking the first guard. Guard mutations to these values do not reach another guard, the performer, or a later emission. This is **per-dispatch isolation**, not an opening-time freeze: `QueryRequest` copies only its outer map, and caller or performer mutations before the next dispatch can affect its next capture. Do not concurrently mutate a value while capture itself is reading it; capture is not an atomic transaction over application memory. The principal, services, and `context.data` retain their existing ownership and are not cloned. The default supported argument graph is deliberately bounded: * Null values and absent map entries remain distinct. Map/list iteration order and scalar runtime types are preserved. * Immutable scalars are accepted by **exact class**, not by a `Number` or temporal interface: `String`, boxed Boolean/Byte/Short/Integer/Long/Float/Double/Character, `BigInteger`, `BigDecimal`, `UUID`, and `java.time` `Instant`, `LocalDate`, `LocalTime`, `LocalDateTime`, `OffsetDateTime`, `OffsetTime`, `ZonedDateTime`, `Duration`, `Period`, `Year`, `YearMonth`, and `MonthDay`. * Ordinary enums and `ArcEnum` constants retain singleton identity only after a structural instance-state check. Every nonstatic field from the constant-specific subclass through its declaring enum must be final and either primitive or declared as an exact **final** immutable scalar class from the list above. `java.lang.Enum`’s own name and ordinal are known immutable. This accepts plain constants and final integer wire values, but rejects nonfinal fields, arrays, collections, enum-valued fields, unknown reference types, and even `BigInteger`/`BigDecimal` fields because those declared classes are not final. Synthetic, transient, and ignored instance fields are not exempt. The check does not read or mutate fields, call application getters, allocate enum instances, or clone constants. It does not guarantee isolation of static/global application state or arbitrary method side effects. * Primitive arrays and object arrays retain their component classes, including boxed, UUID, checked enum (also constant-specific subclass), concept, and nested arrays. Lists reconstruct as `ArrayList`; string-key maps reconstruct as `LinkedHashMap`. Container implementation identity, aliases, and extra implementation state are not preserved. An array whose component cannot accept the reconstructed value (for example a nonempty `LinkedList[]`) is unsupported. * Concrete final, nonlocal, nonanonymous, nongeneric `ConceptAs` classes may carry one non-null supported scalar. Across their class hierarchy they must have exactly one instance field, whose declared type (boxed if primitive) equals that scalar’s runtime class; additional instance state, including ignored or transient state, is unsupported. Concepts must serialize as non-null JSON scalars and reconstruct with exactly the same concept type and scalar type/value. The default Arc mapper additionally requires an accessible public single-value constructor. This includes supported Kotlin scalar wrappers and public Java records; it is not general model or polymorphic graph cloning. Enums whose instance state fails the check, sets, arbitrary model objects, custom numeric/temporal implementations, cyclic graphs, and unsupported concepts fail closed before any guard runs rather than falling back to the original value. Shared acyclic inputs are independently expanded, not preserved as aliases. The maximum capture depth is 64 (outer argument map is depth 0), and at most 10,000 value nodes may be captured, counting the outer map, each container/concept/scalar/null occurrence, and repeated aliases. Reconstruction permits at most 100,000 node occurrences in total across every guard copy **plus one discarded preflight copy**. Map keys are not separate nodes. Exceeding either budget denies; nothing is truncated. Container sizes are checked before bulk allocation, and repeated subgraphs consume the same budget to bound recursive amplification. The no-argument and iterable-only constructors retain their source and binary call shapes and use `ArcObjectMapper.create()`. The additive `(guards, mapper)` constructor accepts the authoritative application mapper. Spring supplies its mapper bean when present (and retains the default when none exists); an application mapper is not silently reconfigured. `ObservableQueryScenario` uses an Arc mapper with the scenario’s actual derived-type registry. Supplying a mapper does **not** enable polymorphic argument support or bypass discriminator validation. Custom concept codecs must be deterministic, preserve the scalar contract, and return fresh independent instances without retaining or later mutating guard-owned values. Runtime checks reject returned originals, repeated instances within one dispatch (including the discarded preflight copy), scalar/type changes, nonscalar JSON, and round-trip JSON changes. These checks detect obvious cached/shared codecs; they cannot prove purity or independence of arbitrary stateful application code. Such codecs remain the application’s responsibility. Unsupported values and ordinary capture/reconstruction/guard failures deny and terminate before unsafe values reach any guard. All argument copies are prepared before guard one, so even a later copy failure invokes no guards. Guard invocation order, most-restrictive verdict aggregation, and deny short-circuiting are unchanged. Active coroutine cancellation propagates during capture, reconstruction, and awaiting guards; an independently cancelled guard future while its caller remains active still denies. With no guards, the aggregator returns `ALLOW` immediately without inspecting arguments. Synchronous application codecs must remain cooperative; cancellation checks cannot preempt blocking user code. This is a **behavioral tightening**, not universally backward-compatible behavior: previously accepted guarded argument shapes can now terminate unauthorized. No wire schema or manifest version changes. Applications requiring broader argument ownership semantics must explicitly replace the existing `ObservableQueryEmissionGuards` aggregator and own that contract; there is no new public copy-policy SPI or unsafe fallback. ## Inspect observable health `QueryHealthTracker` records physical connections, activity timestamps, pong timestamps, and active subscriptions. It exposes both a snapshot and an observable `Flow`. The Spring host publishes the current snapshot at `GET /.cratis/queries/health` and RFC QUERY on the same route. The transport removes health entries when connections close or fail. The snapshot reports connection and subscription identifiers, remote IP addresses, user agents, and user identities, so both methods require an authenticated caller whenever Arc authentication handlers are registered; anonymous callers receive 401. Applications with no `AuthenticationHandler` or `AsyncAuthenticationHandler` bean are unaffected. This is a diagnostics endpoint, not a container liveness probe: use Spring Boot Actuator health groups for liveness and readiness, which Arc never intercepts. # Read-model naming policy > Control how Arc derives MongoDB collection names from read-model types, how the same names reach Chronicle projections, and how to override the default for types where the kernel and the JVM inflector disagree. ## How Arc names read-model collections When Arc resolves a MongoDB read-model collection, it delegates to a `NamingPolicy` bean to convert the JVM type into a storage name. The `NamingPolicy` interface declares two methods: * `getReadModelName(Class)` — returns the collection name for a read-model type. Must be non-blank and stable across calls for the same type. * `getPropertyName(String)` — returns the stored field name for a declared member name. The default implementation returns the name unchanged. - Java ```java interface NamingPolicy { String getReadModelName(Class readModelType); default String getPropertyName(String name) { return name; } } ``` - Kotlin ```kotlin interface NamingPolicy { fun getReadModelName(readModelType: Class<*>): String fun getPropertyName(name: String): String // default: returns name unchanged } ``` ## Default policy `DefaultNamingPolicy` is registered automatically by the MongoDB integration. It pluralizes the simple class name using [Evo Inflector](https://github.com/atteo/evo-inflector) (`org.atteo:evo-inflector`), except for a small built-in list of nouns where Evo Inflector and the kernel disagree: `Person` becomes `People` and `Index` becomes `Indices`, matching the kernel. * Java ```java NamingPolicy pluralising = new DefaultNamingPolicy(); NamingPolicy asDeclared = new DefaultNamingPolicy(false); ``` * Kotlin ```kotlin val pluralising: NamingPolicy = DefaultNamingPolicy() val asDeclared: NamingPolicy = DefaultNamingPolicy(pluralizeReadModelNames = false) ``` Setting `pluralizeReadModelNames = false` returns the simple class name unchanged. ## Chronicle projections use the same names With the Chronicle integration (`io.cratis:arc-chronicle-spring-boot-starter`, or `io.cratis:cratis`) next to the MongoDB integration, Arc’s `NamingPolicy` also names the collection Chronicle projects each read model into. The integration adapts the single `NamingPolicy` bean into a Chronicle `ReadModelNamingPolicy` bean, and the Chronicle Spring Boot starter gives that bean to the `ChronicleOptions` it builds. A projected `Author` therefore lands in `Authors`, the collection Arc reads by default, with no configuration: * The default `DefaultNamingPolicy` flows through, and so does a custom `NamingPolicy` bean that replaces it. * The policy names only the collection. The read model identifier, and everything keyed on it (projections, reducers, observers), is unchanged, and an explicit `@ReadModel(id = ...)` no longer changes the collection name. * Declare your own `ReadModelNamingPolicy` bean to name Chronicle’s collections differently. It wins over the adapter, and Arc keeps reading through its `NamingPolicy`, so keep the two consistent. * Without a `NamingPolicy` bean, which means without the MongoDB integration, Chronicle keeps naming the collection after the read model identifier. * An application that declares its own `ChronicleOptions` bean builds the options itself, so it must call `withReadModelNamingPolicy` there. - Java ```java @Configuration public class ChronicleNamingConfig { // Names only the collections Chronicle projects into; Arc keeps reading through its own NamingPolicy. @Bean public ReadModelNamingPolicy readModelNamingPolicy() { return readModelClass -> "projected_" + readModelClass.getSimpleName(); } } ``` - Kotlin ```kotlin @Configuration class ChronicleNamingConfig { // Names only the collections Chronicle projects into; Arc keeps reading through its own NamingPolicy. @Bean fun readModelNamingPolicy(): ReadModelNamingPolicy = ReadModelNamingPolicy { readModelClass -> "projected_${readModelClass.simpleName}" } } ``` Existing data does not follow a renamed collection. Chronicle does not move or delete documents when a read model’s collection name changes, so a read model already projected under an earlier name stays in its old collection. Replay the projection to fill the new collection, and drop the old one yourself when you no longer need it. ## The Chronicle Kernel is authoritative for .NET clients Pluralization on the JVM uses Evo Inflector, a different library from the Humanizer library used by the .NET Chronicle client. The two libraries agree on most common English nouns, but where they disagree on a specific noun, a collection written by a .NET client follows Humanizer. The built-in corrections cover only the nouns measured so far; for any other noun that differs, override the naming policy for that type so that Arc reads the collection the .NET client writes to. Read models projected through the JVM Chronicle integration need no such correction, because Chronicle then uses Arc’s policy for the collection name. The cases pinned by `DefaultNamingPolicyTests` record what Evo Inflector actually produces. A library upgrade that changes a plural will fail those tests rather than silently moving a collection name. ## Override the policy for a specific type Register a Spring bean that implements `NamingPolicy` and is annotated with `@ConditionalOnMissingBean` in your autoconfiguration, or supply it directly: * Java ```java @Configuration class NamingConfig { @Bean NamingPolicy namingPolicy() { return readModelType -> { // The kernel writes to "PersonProjections"; the JVM inflector would produce "People". if (readModelType == PersonView.class) return "PersonProjections"; return new DefaultNamingPolicy().getReadModelName(readModelType); }; } } ``` * Kotlin ```kotlin @Configuration class NamingConfig { @Bean fun namingPolicy(): NamingPolicy = object : NamingPolicy { override fun getReadModelName(readModelType: Class<*>): String = when (readModelType) { // The kernel writes to "PersonProjections"; the JVM inflector would produce "People". PersonView::class.java -> "PersonProjections" else -> DefaultNamingPolicy().getReadModelName(readModelType) } } } ``` Arc calls the `NamingPolicy` bean on every collection lookup and does not cache the result. Keep `getReadModelName` cheap and deterministic, and cache inside your implementation when the computation is expensive. # Authenticate and authorize Arc endpoints > Register Kotlin and Java authentication handlers, declare endpoint authorization, and expose application identity details safely. ## Establish trust before accepting platform headers The optional platform identity bridge is **off by default**. Base64 is unsigned data, never proof of sender identity. Enable `cratis.arc.platform-identity.enabled=true` only behind authenticated ingress that strips caller-supplied `x-ms-client-principal`, `x-ms-client-principal-id`, and `x-ms-client-principal-name` headers and rewrites them from verified identity. Block direct backend access. Header parsing alone cannot establish either of these deployment guarantees. Supply an `ArcPlatformIdentityTrust` bean that verifies your deployment’s ingress boundary on each submission. Without it, the default policy rejects every platform submission. Do not return true because `X-Forwarded-For`, `Forwarded`, or another caller-authored header claims a trusted address. An exact `request.remoteAddr` comparison can be part of an isolated deployment’s policy, but only when it represents a verified transport peer. Forwarded-request wrappers and container valves can rewrite it: disable address rewriting or configure and verify trusted proxy handling before using this value. Do not resolve caller-provided hostnames or grant trust to the public client address. Arc does not install forwarding configuration, TLS credentials, firewall rules, or proxy allowlists. Spring Security remains optional. With security absent or the property disabled, Arc registers no platform converter, filter, or chain, and platform headers do not authenticate requests. ### Choose who owns the security chain When enabled and no application `SecurityFilterChain` exists, Arc supplies a conservative chain: **every route requires authentication**, including otherwise anonymous Arc metadata routes. CSRF, security headers, and Spring’s session defaults remain enabled; POST and other unsafe requests need a valid CSRF token. Missing authentication returns 401, access denial (including CSRF) returns 403, without login redirects or error redispatch. Form login and HTTP Basic are not added by this chain. This policy deliberately favors denial over accidentally exposing application routes; configure an application chain for any anonymous routes or alternative authentication mechanisms. An application chain is never rewritten. Explicitly add the provided filter after your existing authentication mechanisms and before `AnonymousAuthenticationFilter`, as in this configuration: * Java ```java @Bean SecurityFilterChain applicationChain(HttpSecurity http, ArcPlatformAuthenticationFilter filter) throws Exception { http.addFilterBefore(filter, AnonymousAuthenticationFilter.class); http.authorizeHttpRequests(rules -> rules.requestMatchers("/.cratis/commands").permitAll() .anyRequest().authenticated()); http.exceptionHandling(errors -> errors.authenticationEntryPoint( (request, response, failure) -> response.setStatus(401))); return http.build(); } ``` * Kotlin ```kotlin @Bean fun applicationChain(http: HttpSecurity, filter: ArcPlatformAuthenticationFilter): SecurityFilterChain { http.addFilterBefore(filter, AnonymousAuthenticationFilter::class.java) http.authorizeHttpRequests { rules -> rules.requestMatchers("/.cratis/commands").permitAll().anyRequest().authenticated() } http.exceptionHandling { errors -> errors.authenticationEntryPoint { _, response, _ -> response.status = 401 } } return http.build() } ``` Keep the disabled `arcPlatformAuthenticationFilterRegistration` bean: it prevents Boot servlet registration and double execution outside Spring Security. With multiple application chains, install the filter only in chains intended to accept this identity. A chain without it ignores the headers. Do not register it independently as a servlet filter. Applications may replace the trust bean, the named `arcPlatformAuthenticationConverter` (`AuthenticationConverter`), or the filter bean; custom converters own equivalent validation and must report rejected credentials as `AuthenticationException`. Unrelated converters do not disable the named default. Valid platform submissions never replace an already authenticated non-anonymous Spring identity. Invalid submissions fail with a generic 401 even when an existing identity is present (CSRF or an earlier security filter may reject first). The bridge saves its context only in request attributes for redispatch, never in the HTTP session; keep Spring’s request-attribute security-context repository support when customizing a chain. The servlet thread context is restored on exit. Arc captures the resulting principal before coroutine work, without later servlet or security thread-local reads. The client-readable identity cache cookie cannot authenticate a later request. ### Understand the bounded header contract A submission requires exactly one of each of the three headers; no headers means no attempt. Partial or repeated headers, invalid base64, malformed UTF-8/JSON, duplicate JSON keys, trailing values, unknown fields, incorrect types, duplicate claim pairs, duplicate roles, control characters, and exceeded limits fail with no payload or parser cause in the failure. Canonical padded standard base64 is required, not URL-safe base64 or whitespace-wrapped data. Configure your container and proxy header limits too: they may reject a request before Arc sees it. The JSON object accepts `identityProvider`, `userId`, `userDetails`, `userRoles`, and `claims` only. `userDetails` is a required nonblank display name. Optional `userId` is a nonblank string but is not the canonical ID. Optional `identityProvider` is a string; missing or blank means no provider claim. `userRoles` is an optional array of nonblank strings; `claims` is an optional array of exact `{"typ":"type","val":"value"}` objects. Explicit null is not absence. Claim types are nonblank; empty claim values are allowed. ID/name headers must be nonblank; the name header is required by the wire contract, but the display name comes from `userDetails`. Limits are fixed: 32,768 encoded characters, 24,576 decoded bytes, 2,048 characters per string/header ID/name, 128 input claims, 64 roles (including role claims), nesting depth 4, 2,048 JSON tokens, 256-character JSON property names, and 32-character numeric tokens. There is no application-mapper coercion or polymorphic type activation. Repeated claim types with distinct values remain multivalued. The ID header replaces exact `sub` and the standard nameidentifier claim. Reserved `urn:cratis:arc:identity:provider` collisions are removed case-insensitively, then one nonblank `identityProvider` value is retained verbatim, including surrounding spaces. Other claims, including `urn:cratis:identity:provider-key`, survive. Standard name and role claims are added from `userDetails` and `userRoles`; standard role claims also become Spring `ROLE_` authorities. Arc captures these as unprefixed roles and scheme `MicrosoftIdentityPlatform`. This scheme describes the bridge, not an independently verified signature or a claim that the application uses Microsoft token validation. These JVM security boundaries are intentionally stricter than the inspected Arc .NET handler, including rejecting partial submissions rather than treating them as anonymous. Do not log the headers, claims, or identity cookie when troubleshooting ingress. ## Register an authentication handler Arc authenticates requests through ordered `AuthenticationHandler` beans before protected command, query, identity, and diagnostics endpoints run. A handler receives an immutable `AuthenticationRequestContext` containing case-insensitive headers, cookies, the principal captured by the host, and the selected tenant when one exists. Return `AuthenticationResult.ANONYMOUS` when the handler does not recognize the request. Return `failed` when it recognizes credentials but rejects them, or `succeeded` with an `ArcPrincipal` when it accepts them: * Java ```java @Bean @Order(10) AsyncAuthenticationHandler bearerAuthentication() { return context -> { String value = context.header("Authorization"); AuthenticationResult result; if (value == null) { result = AuthenticationResult.ANONYMOUS; } else if (value.equals("Bearer valid-token")) { result = AuthenticationResult.succeeded( new ArcPrincipal("Ada", true, Set.of("admin"), "user-42", List.of(), "Bearer")); } else { result = AuthenticationResult.failed(AuthenticationFailureReason.of("invalid-token")); } return CompletableFuture.completedFuture(result); }; } ``` * Kotlin ```kotlin @Bean @Order(10) fun bearerAuthentication(): AuthenticationHandler = AuthenticationHandler { context -> when (context.header("Authorization")) { null -> AuthenticationResult.ANONYMOUS "Bearer valid-token" -> AuthenticationResult.succeeded( ArcPrincipal( name = "Ada", isAuthenticated = true, roles = setOf("admin"), id = "user-42", authenticationScheme = "Bearer" ) ) else -> AuthenticationResult.failed(AuthenticationFailureReason.of("invalid-token")) } } ``` A success or failure is terminal: no later handler runs. A failed handler can therefore never be overridden by a later success. Anonymous is not a rejection and lets the chain continue; it becomes the final result only when no handler recognized the request. The HTTP response exposes failures only as a generic 401 and never returns the handler’s private failure reason. Under Spring Boot, `AuthenticationHandler` and `AsyncAuthenticationHandler` beans form one chain in global Spring `@Order`, whichever style each is written in, so a Kotlin handler and a Java handler interleave by their order values. Only a directly constructed configuration concatenates the two lists instead. The Java tab implements `AsyncAuthenticationHandler` with `CompletionStage`, without coroutine types. Register the async handler itself as a bean; Arc adapts it to the coroutine-first chain and propagates cancellation. Do not block the request thread while verifying credentials. ## Declare authorization on artifacts and operations Arc generates authorization metadata from `@Authorize`, `@Roles`, and `@AllowAnonymous` on a command or read-model class and its operation (`handle` or the query function). * Java ```java @Authorize(policy = "activeSubscription") @Roles("member") @Command public class UpdateProfile { public void handle() { } } ``` * Kotlin ```kotlin @Authorize(policy = "activeSubscription") @Roles("member") @Command class UpdateProfile { fun handle(): Unit = Unit } ``` An authorization decision with several dimensions requires all declared dimensions: * The caller must be authenticated unless `@AllowAnonymous` applies. * A named policy must return `AuthorizationResult.success()`. * The caller must hold at least one role from the combined role list. * The captured authentication scheme must match at least one declared scheme, ignoring case. Register named policies as Spring beans. The bean name is the policy name used by `@Authorize`: * Java ```java @Bean("activeSubscription") AuthorizationPolicy activeSubscription() { return new BlockingAuthorizationPolicyAdapter(principal -> principal.getClaims().stream() .anyMatch(claim -> claim.getType().equals("subscription") && claim.getValue().equals("active")) ? AuthorizationResult.success() : AuthorizationResult.failure("An active subscription is required.")); } ``` * Kotlin ```kotlin @Bean("activeSubscription") fun activeSubscription(): AuthorizationPolicy = AuthorizationPolicy { principal -> if (principal.claims.any { it.type == "subscription" && it.value == "active" }) { AuthorizationResult.success() } else { AuthorizationResult.failure("An active subscription is required.") } } ``` Java policies use `BlockingAuthorizationPolicyAdapter` or `AsyncAuthorizationPolicyAdapter`, so Java implementations never implement a suspending method. ## Understand class and operation precedence The class states the default. An operation that declares any authorization metadata of its own — `@AllowAnonymous`, `@Authorize`, or `@Roles` — replaces that default entirely rather than merging with it. Only when an operation declares none does the class apply. Replacement rather than merging is what makes narrowing trustworthy. An operation that asks for `@Roles("admin")` requires exactly that role, and a role the class happened to list cannot satisfy it by accident. The same rule reads in the other direction, which is how a protected read model exposes the one query a login screen needs before anybody has signed in: * Java ```java @ReadModel @Authorize public record AuthenticationQueryItem(String message) { // Overrides the class: anyone may subscribe to this one. @AllowAnonymous public static Flow.Publisher anonymous( @FromServices AuthenticationQuerySource source) { return source.observeAnonymous(); } // Declares nothing, so the class-level @Authorize applies. public static Flow.Publisher authenticated( @FromServices AuthenticationQuerySource source) { return source.observeAuthenticated(); } } ``` * Kotlin ```kotlin @ReadModel @Authorize public data class AuthenticationQueryItem(public val message: String) { public companion object { // Overrides the class: anyone may subscribe to this one. @JvmStatic @AllowAnonymous public fun anonymous(@FromServices source: AuthenticationQuerySource): Flow = source.observeAnonymous() // Declares nothing, so the class-level @Authorize applies. @JvmStatic public fun authenticated(@FromServices source: AuthenticationQuerySource): Flow = source.observeAuthenticated() } } ``` Repeated `@Roles` declarations on the same target combine, and holding any one listed role satisfies the role check. Policy, role, and scheme checks are still cumulative. One declaration cannot both open and restrict `@AllowAnonymous` together with `@Authorize` or `@Roles` **on the same class or the same operation** is a contradiction with no defensible reading. KSP reports `ARCKSP0108` and stops generation rather than guessing. Splitting them across a class and its operation is a different thing entirely, and is exactly the override above. Declaring `@AllowAnonymous` on both the class and its operation is also `ARCKSP0108`: it is redundant, so declare it once. ## Use Spring Security when present Spring Security remains optional. When it is on the classpath, Arc maps the request’s Spring `Authentication` into an `ArcPrincipal`, retaining its name, authenticated state, string-valued authorities, claims, stable identity identifier, and authentication scheme. Authorities prefixed with `ROLE_` become Arc role names without the prefix; authorities without a string representation are ignored. Without Spring Security, Arc uses the servlet principal. In both cases Arc captures the principal before asynchronous work begins, so command and query pipelines never depend on thread-local security state after suspension. An application can replace Arc’s complete `Authentication` service with its own bean. Supplying that override bypasses the default ordered handler aggregator and is appropriate only when the application owns the entire chain. ## Expose application identity details Register one `IdentityDetailsProvider` to enable `GET /.cratis/me`. It receives the authenticated principal and returns both the application’s authorization decision and its typed identity payload: * Java ```java record ApplicationIdentity(String displayName) { } @Bean AsyncIdentityDetailsProvider identityDetails() { return new AsyncIdentityDetailsProvider<>() { @Override public Class getDetailsType() { return ApplicationIdentity.class; } @Override public CompletionStage> provide(IdentityProviderContext context) { boolean authorized = context.getClaims().stream() .anyMatch(claim -> claim.getType().equals("role") && claim.getValue().equals("member")); return CompletableFuture.completedFuture( new IdentityDetails<>(authorized, new ApplicationIdentity(context.getName()))); } }; } ``` * Kotlin ```kotlin data class ApplicationIdentity(val displayName: String) @Bean fun identityDetails(): IdentityDetailsProvider = object : IdentityDetailsProvider { override val detailsType = ApplicationIdentity::class.java override suspend fun provide(context: IdentityProviderContext): IdentityDetails = IdentityDetails( isUserAuthorized = context.claims.any { it.type == "role" && it.value == "member" }, details = ApplicationIdentity(context.name) ) } ``` Java registers an `AsyncIdentityDetailsProvider` bean directly, as in the Java tab; Arc adapts it. Arc permits exactly one identity-details provider and fails application startup when several are present. The identity cache cookie is client-readable by design and is excluded from authentication input, so a client can never authenticate by replaying Arc’s cached identity projection. ## Know which built-in routes are anonymous The following rules describe Arc’s own authentication layer; an application or the enabled platform Spring Security chain can impose stricter rules before Arc runs. When no Arc authentication handlers are registered, Arc endpoint behavior is unchanged and the captured host principal is used. Once handlers exist, the following literal metadata routes remain anonymous: * `GET /.cratis/commands` * `GET /.cratis/queries` * `GET /.cratis/users` — see [Development users and tenants](/arc/backend/kotlin/guides/development-users-and-tenants/) * `GET /.cratis/tenants` — see [Development users and tenants](/arc/backend/kotlin/guides/development-users-and-tenants/) * `GET /.cratis/identity-details/schema` `GET /.cratis/me` requires an authenticated principal because it returns caller-specific identity details. The fixed multiplexed SSE and WebSocket routes accept an anonymous physical connection, while every subscription still runs its query’s authorization pipeline independently. One unauthorized subscription terminates without disturbing authorized subscriptions sharing the connection. `GET` and `QUERY /.cratis/queries/health` require authentication whenever handlers are registered because the snapshot includes connection identifiers, subscription identifiers, remote addresses, user agents, and user identities. This endpoint is diagnostics, not container health; use Spring Boot Actuator health groups for liveness and readiness. ## Distinguish authentication and authorization failures Authentication establishes who the caller is and fails with HTTP 401 when credentials are rejected or a protected endpoint receives no authenticated principal. Authorization decides whether that principal may execute a specific command or query and fails with HTTP 403. Arc returns generic transport failures and keeps private handler or policy details out of the response. Use server-side debug logs, traces, and the stable result envelope when diagnosing a rejection; never return credential or policy internals to the caller. # Use Spring Data read models > Inject JPA and MongoDB repositories into model-bound queries and commands. ## Add an integration Choose the store used by the application: ```kotlin // JPA dependencies { implementation("io.cratis:arc-spring-data-jpa:") } ``` ```kotlin // MongoDB dependencies { implementation("io.cratis:arc-spring-data-mongodb:") } ``` Both modules include the Arc Spring Boot starter and the corresponding Spring Data starter. Spring Boot continues to own the datasource, entity manager, Mongo client, repository discovery, and their standard configuration. ## Inject a repository into a query Spring Data repositories are ordinary Spring services. Mark a model-bound query dependency with `@FromServices`; Arc’s generated performer resolves the repository from the current application context. * Java ```java @Entity @ReadModel public class TaskView { @Id public String id; public String title; public static List all(@FromServices TaskViewRepository tasks) { return tasks.findAll(); } } interface TaskViewRepository extends JpaRepository { } ``` * Kotlin ```kotlin @Entity @ReadModel data class TaskView(@Id val id: String = "", val title: String = "") { companion object { @JvmStatic fun all(@FromServices tasks: TaskViewRepository): List = tasks.findAll() } } interface TaskViewRepository : JpaRepository ``` The same pattern works with `MongoRepository`, repository fragments, and application query services. An application bean replaces an auto-configured adapter bean of the same integration contract. ## Then Storing a domain concept, routing by tenant and streaming changes each need their own setup: * [Map JPA concepts](/arc/backend/kotlin/guides/spring-data/concepts-jpa/) — attribute converters and embedded ids. * [Map MongoDB concepts](/arc/backend/kotlin/guides/spring-data/concepts-mongodb/) — concrete scalar pairs and identifiers. * [Repositories in queries and commands](/arc/backend/kotlin/guides/spring-data/repositories/) — paging, current state, tenant-bound access and transactions. * [Observable storage snapshots](/arc/backend/kotlin/guides/spring-data/observable-snapshots/) — change streams and JPA notifications. # Map JPA concepts > Persist a ConceptAs value through a JPA attribute converter, including single-column embedded ids. ## Map JPA concepts explicitly in the application `ConceptAs` is not a persistence mapping or an automatic converter registration. Own each concrete mapping in the application, configure it before creating and certifying each persistence unit, and keep converters stateless and tenant-independent. Arc does not install converters during lookup or infer concept constructors. ### Convert ordinary attributes Use a concrete `AttributeConverter` with `@Converter(autoApply = false)` and apply it with `@Convert` to each ordinary attribute. Preserve null in both directions; construct non-null values explicitly and let invalid values fail rather than substituting a default: * Java ```java public record TextValue(String value) implements ConceptAs { public TextValue { Objects.requireNonNull(value); if (value.isBlank()) throw new IllegalArgumentException("Text concept must not be blank."); } } @Converter(autoApply = false) public class TextConverter implements AttributeConverter { @Override public String convertToDatabaseColumn(TextValue attribute) { return attribute == null ? null : attribute.value(); } @Override public TextValue convertToEntityAttribute(String dbData) { return dbData == null ? null : new TextValue(dbData); } } ``` * Kotlin ```kotlin data class TextValue(private val scalar: String) : ConceptAs { init { require(scalar.isNotBlank()) { "Text concept must not be blank." } } override fun value(): String = scalar } @Converter(autoApply = false) class TextConverter : AttributeConverter { override fun convertToDatabaseColumn(attribute: TextValue?): String? = attribute?.value() override fun convertToEntityAttribute(dbData: String?): TextValue? = dbData?.let(::TextValue) } ``` The Java form is an explicit concrete record converter, not a generic erased `ConceptAs` converter. The executable recipes also provide concrete UUID-to-UUID and Long-to-Long converters. Their H2 columns are `UUID`, `CHARACTER VARYING`, and `BIGINT`, not serialized wrapper objects. Numeric coverage is signed 64-bit `Long` only, including both boundaries and values beyond JavaScript’s exact integer range; it does not establish another numeric type or a client numeric contract. ### Use a one-column embedded concept identifier For concept identifiers, use `@Embeddable` and `@EmbeddedId` with one scalar attribute, an explicit column override, and value equality. This is an **embedded identifier** recipe, not a claim that an attribute converter on a basic `@Id` is portable. Never mutate an identifier after assigning it to a managed entity: * Java ```java @Embeddable public record UuidId(UUID value) implements ConceptAs, Serializable { } @Entity @ReadModel public class UuidRow { @EmbeddedId @AttributeOverride(name = "value", column = @Column(name = "concept_id")) public UuidId id; @Convert(converter = TextConverter.class) @Column(name = "text_field") public TextValue textField; public UuidRow() { } } ``` * Kotlin ```kotlin @Embeddable data class UuidId(var scalar: UUID = UUID(0, 0)) : ConceptAs, Serializable { override fun value(): UUID = scalar } @Entity @ReadModel open class UuidRow( @field:EmbeddedId @field:AttributeOverride(name = "scalar", column = Column(name = "concept_id")) open var id: UuidId = UuidId() ) { @field:Convert(converter = TextConverter::class) @field:Column(name = "text_field") open var textField: TextValue? = null } ``` The Kotlin fixture supplies a JPA no-argument constructor through defaults and mutable scalar slots for field hydration. The Java fixtures execute UUID, String, and Long **record embeddables** on Hibernate 7.4.5.Final with H2 2.5.250; record support here is evidence for that provider/database combination, not certification of other JPA providers. Declare the repository identifier as the concept type, for example `JpaRepository`, and supply `new UuidId(value)` from Java or `UuidId(value)` from Kotlin. Concept-valued derived predicates bind through the ordinary attribute’s converter. A managed entity’s ordinary concept attributes can be replaced and flushed without another `save`; reload from a fresh persistence context to verify storage rather than the first-level cache. ### Evidence and routing boundary `Integrations/SpringDataJpa` tests `io.cratis.arc.persistence.recipes.jpa.JpaConceptStorageTests` and `JavaJpaConceptStorageTests` execute real in-process H2 storage with explicit per-unit mappings, repository `findById`, concept-valued predicates, raw SQL column values and types, flush/clear and fresh reload, nullable fields, dirty replacement, malformed stored values, and signed Long boundaries. Missing `@Convert` fails factory creation even with a registered non-auto-applying converter; that is an application mapping omission control, not an Arc product defect. The tests first assert the provider’s `hasSingleIdAttribute()` and embedded `idType`, then pass that exact concept key through the existing contextual Arc resolver. The same keys with different values in two certified H2 factories remain isolated; unknown tenants, wrong namespaces, mismatched certificates, raw scalar keys, and a different concept class fail without a fixed-store retry. Configure all mappings before issuing the `JpaPersistenceUnit` certificate. Repository injection itself is still ordinary Spring dependency injection, not tenant routing. These application-owned recipes add no Arc API, automatic persistence feature, or Arc .NET parity claim; other databases and providers remain unverified. Run the bounded recipe checks with: ```shell ./gradlew :Integrations:SpringDataJpa:test --tests '*JpaConceptStorageTests' ``` # Map MongoDB concepts > Register concrete scalar pairs and identifiers so a ConceptAs value round-trips through MongoDB. ## Map MongoDB concepts explicitly in the application MongoDB concept storage is also an **application-owned recipe**, not Arc autodiscovery. Register concrete, stateless `@WritingConverter`/`@ReadingConverter` pairs for each concept and scalar in one authoritative `MongoCustomConversions.create` configuration. A bare Spring `Converter` bean is not this registration. Configure the application’s mapping context and converter before creating templates, repositories, or tenant certificates; Arc must not mutate an application-owned custom `MongoConverter` during lookup. ### Register concrete scalar pairs For a concept such as the `TextValue` shown in [Map JPA concepts](/arc/backend/kotlin/guides/spring-data/concepts-jpa/), the pair is: * Java ```java @WritingConverter public class TextWrite implements Converter { @Override public String convert(TextValue source) { return source.value(); } } @ReadingConverter public class TextRead implements Converter { @Override public TextValue convert(String source) { return new TextValue(source); } } ``` * Kotlin ```kotlin @WritingConverter class TextWrite : Converter { override fun convert(source: TextValue): String = source.value() } @ReadingConverter class TextRead : Converter { override fun convert(source: String): TextValue = TextValue(source) } ``` Here `Converter` means Spring’s `org.springframework.core.convert.converter.Converter`, not the JPA annotation. The executable recipes provide equivalent concrete UUID-to-UUID and Long-to-Long pairs named `UuidWrite`/`UuidRead` and `LongWrite`/`LongRead`. Do not replace them with an erased `Converter, ?>` or infer constructors reflectively. Install all six together: * Java ```java MongoCustomConversions conversions = MongoCustomConversions.create(adapter -> { adapter.registerConverter(new UuidWrite()); adapter.registerConverter(new UuidRead()); adapter.registerConverter(new TextWrite()); adapter.registerConverter(new TextRead()); adapter.registerConverter(new LongWrite()); adapter.registerConverter(new LongRead()); }); ``` * Kotlin ```kotlin val conversions = MongoCustomConversions.create { adapter -> adapter.registerConverter(UuidWrite()) adapter.registerConverter(UuidRead()) adapter.registerConverter(TextWrite()) adapter.registerConverter(TextRead()) adapter.registerConverter(LongWrite()) adapter.registerConverter(LongRead()) } ``` For programmatically owned templates, use that same configuration in both places, in this order (the complete `RecipeMongoStore` fixture also owns and closes its client): * Java ```java static TextRows textRows(MongoClient client, String database, MongoCustomConversions conversions) { var mappingContext = new MongoMappingContext(); mappingContext.setSimpleTypeHolder(conversions.getSimpleTypeHolder()); mappingContext.setInitialEntitySet(Set.of(UuidRow.class, TextRow.class, LongRow.class)); mappingContext.afterPropertiesSet(); var factory = new SimpleMongoClientDatabaseFactory(client, database); var converter = new MappingMongoConverter(new DefaultDbRefResolver(factory), mappingContext); converter.setCustomConversions(conversions); converter.afterPropertiesSet(); var template = new MongoTemplate(factory, converter); return new MongoRepositoryFactory(template).getRepository(TextRows.class); } ``` * Kotlin ```kotlin fun textRows(client: MongoClient, database: String, conversions: MongoCustomConversions): TextRows { val mappingContext = MongoMappingContext() mappingContext.setSimpleTypeHolder(conversions.simpleTypeHolder) mappingContext.setInitialEntitySet(setOf(UuidRow::class.java, TextRow::class.java, LongRow::class.java)) mappingContext.afterPropertiesSet() val factory = SimpleMongoClientDatabaseFactory(client, database) val converter = MappingMongoConverter(DefaultDbRefResolver(factory), mappingContext) converter.customConversions = conversions converter.afterPropertiesSet() val template = MongoTemplate(factory, converter) return MongoRepositoryFactory(template).getRepository(TextRows::class.java) } ``` Configure the client explicitly with `MongoClientSettings.builder().uuidRepresentation(UuidRepresentation.STANDARD)` before building it. The tests read raw `BsonDocument` values and assert UUID binary **subtype 4**, BSON string, and BSON int64 for IDs and ordinary fields, not nested wrappers or numeric strings. ### Choose identifier and null mappings deliberately The recipes use Spring Data `@Id` for UUID and Long concept identifiers, with the concrete pairs above. For the String concept identifier, use `@MongoId(FieldType.STRING)` explicitly: the tested 24-hex value `507f1f77bcf86cd799439011` stays a BSON string instead of becoming an `ObjectId`. Do not substitute an unqualified `@MongoId` for these mappings; its implicit target is not the same conversion policy. Declare repository IDs as the concrete concept type, for example `MongoRepository`. Kotlin documents use constructor-bound concept IDs and mutable ordinary fields. Ordinary Java documents use no-argument construction and field hydration, with immutable concept records as their values; this avoids relying on Java constructor parameter-name compiler metadata. Do not change an identifier after saving a document. Spring bypasses these scalar converters for null. Apply `@Field(write = Field.Write.ALWAYS)` (`@field:Field(write = Field.Write.ALWAYS)` in Kotlin) when a nullable concept field must be written as explicit BSON null. Without it, the tested null field is absent. Both read as null; the tests separately remove an explicitly written field and verify absent-field hydration. Non-null blank text fails in the concept constructor. A stored String in the Long field fails with `ConverterNotFoundException` rather than becoming a default Long concept; the configured reading pair accepts Long, not arbitrary malformed storage types. MongoDB documents are not JPA managed entities: changing a loaded field alone does not persist it. Call repository `save` explicitly or use a mapped `MongoTemplate.updateFirst` with a concept-valued query and update. Verify with another read through a fresh template/converter. ### MongoDB recipe evidence and limits `Integrations/SpringDataMongo` tests `io.cratis.arc.persistence.recipes.mongodb.MongoConceptStorageTests` and `JavaMongoConceptStorageTests` execute configured repositories, concept `findById` and equality predicates, fresh class reconstruction, explicit save/update, distinct null/absent BSON shapes, malformed-value failures, and exact signed Long boundaries including values beyond JavaScript’s exact integer range. The missing-registration control stores a nested concept document instead of a scalar; adding the application registration produces the asserted scalar. This is an application omission control, not an existing Arc product bug. Each language stores the same UUID, String, and Long concept keys with different values in two databases. Templates are fully configured before issuing `TenantMongoOperations` certificates. The existing contextual resolver rejects unknown tenants, mismatched certificates, missing/blank tenants, scalar keys, and a different concept key class. Exact resolver-call and driver `find` command counts prove no retry or fallback database read. Mongo certificates certify `tenantId`, not JPA’s tenant namespace; no Mongo namespace-isolation claim follows. Repository injection alone remains ordinary Spring dependency injection, not tenant routing. This evidence uses Spring Data MongoDB 5.1.1 and Mongo Java driver 5.8.1 against the existing **mongo-java-server 1.47.0 `MemoryBackend` emulator**, not a real MongoDB server, Testcontainers, or an external database. It does not certify production MongoDB, other providers or BSON types, change streams, replica sets, transactions, optimistic locking, or Arc .NET parity. No provider dependency or Arc API is added by the recipes. ```shell ./gradlew :Integrations:SpringDataMongo:test --tests '*MongoConceptStorageTests' ``` # Observable storage snapshots > Stream collection changes into an observable query, and the boundary of what the public seam supports. ## Return observable storage snapshots Both Spring Data integrations publish injectable query services whose methods return Kotlin `Flow`. A Flow is cold by default: it reads the initial snapshot when collected, owns its subscription, and closes it on cancellation. `observeShared` is the explicit shared alternative and replays one complete snapshot while subscribers exist. Java callers can use the demand-aware `observePublisher` methods or callback overloads, whose returned `AutoCloseable` cancels collection. ### Observe MongoDB Inject `MongoObservableQuery` into a model-bound query. `observe`, `observeSingle`, and `observeById` re-run the Spring Data query after insert, update, replace, delete, or invalidate notifications. The Kotlin fences on this page are excerpts: each method belongs inside the `companion object` of a `@ReadModel` class such as `TaskView`, as in the [queries guide](/arc/backend/kotlin/guides/queries/). ```kotlin @JvmStatic fun observeTasks( @FromServices queries: MongoObservableQuery ): Flow> = queries.observe(TaskView::class.java) @JvmStatic fun observeTask( id: String, @FromServices queries: MongoObservableQuery ): Flow = queries.observeById(TaskView::class.java, id) ``` `MongoChangeStreamWatcher` is the change-stream SPI. The default `ReconnectingMongoChangeStreamWatcher` uses `SpringDataMongoChangeStreamSource`, resumes from the last token after transient failures, applies capped exponential backoff, and closes its cursor when the collector is canceled. Cursor operations run on `Dispatchers.IO` without a hidden `flowOn` channel. `MongoObservationOptions.bufferCapacity` is exact: zero keeps the producer and collector in rendezvous, while a positive value permits only that many queued changes. MongoDB change streams require a replica set or sharded cluster. A custom `MongoOperationsResolver` can select operations from the captured `tenantId`; it must not consult thread-local request state. Pass the tenant explicitly to the observation method. The default resolver uses the application’s single `MongoOperations` bean. A `Query` supplies filtering, and `observeById` additionally narrows change notifications by document key. Updates and replacements always trigger a fresh snapshot, so a document that stops matching a filter is removed correctly. #### Kotlin type-inference extensions for MongoDB The `arc-spring-data-mongodb` artifact ships Kotlin extension functions on `MongoObservableQuery` that infer the document type from the generic parameter, eliminating the explicit `::class.java` argument. They are top-level functions, so import them by name, for example `import io.cratis.arc.springdata.mongodb.observe` and `import io.cratis.arc.springdata.mongodb.observeById`: ```kotlin @JvmStatic fun observeTasks( @FromServices queries: MongoObservableQuery ): Flow> = queries.observe() @JvmStatic fun observeTask( id: String, @FromServices queries: MongoObservableQuery ): Flow = queries.observeById(id) ``` Criteria-based filtering is also supported without manually wrapping in a `Query`: * Java ```java public static Flow.Publisher> observeActiveTasks(@FromServices MongoObservableQuery queries) { return MongoObservations.observe(queries, TaskView.class, Criteria.where("active").is(true)); } ``` * Kotlin ```kotlin fun observeActiveTasks( @FromServices queries: MongoObservableQuery ): Flow> = queries.observe(Criteria.where("active").`is`(true)) ``` The same extensions cover `observeList`, `observeSingle`, `observeById`, and `observeShared`; each delegates to the corresponding `MongoObservableQuery` method. **Limitation:** extensions cannot be placed on Spring Data’s `MongoCollection`, repository interfaces, or any other foreign type. All `observe*` extensions are on Arc’s own `MongoObservableQuery`. #### Java convenience access for MongoDB Kotlin extensions are invisible from Java. The `MongoObservations` object provides `@JvmStatic` Criteria-based helpers that Java callers can use without manually constructing a `Query`, as the Java tab above shows. Each `MongoObservations` helper wraps the `Criteria` into a `Query` and delegates to the matching `MongoObservableQuery` method. For `Query`-based and callback access, call the `@JvmOverloads` methods on `MongoObservableQuery` directly. The tenant overload is also available: ```java MongoObservations.observe(queries, TaskView.class, Criteria.where("active").is(true), tenantId); ``` ### Observe JPA Inject `JpaObservableQuery` and return `observe`, `observeList`, `observeSingle`, or `observeById` from the read-model query. The default list query uses the mapped JPA entity name; `JpaSnapshotQuery` provides a Java-friendly customization seam for predicates, ordering, and fetch joins. * Java ```java public static Flow.Publisher> observeTasks(@FromServices JpaObservableQuery queries) { return JpaObservations.observePublisher(queries, TaskView.class); } ``` * Kotlin ```kotlin @JvmStatic fun observeTasks( @FromServices queries: JpaObservableQuery ): Flow> = queries.observe(TaskView::class.java) ``` **Limitation:** JPA observation is in-process only. It is backed by `TransactionAwareDatabaseChangeNotifier`, an explicit in-process publisher: call its `DatabaseChangePublisher.publish(TaskView::class.java, tenantId)` contract from the write side. Unlike MongoDB change streams there is no cross-process change mechanism; applications needing cross-process notifications must replace the `DatabaseChangeNotifier` bean with a database-native implementation and expose a corresponding `DatabaseChangePublisher` where local writes also need publishing. Arc does not silently poll either store; polling must be an application-owned, explicitly configured notifier. Notifications are coalesced per transaction, discarded on rollback, and emitted only after commit. `JpaObservationOptions.bufferCapacity` must be positive and bounds pending invalidations; when it is full, the oldest invalidation is replaced because every notification causes a complete snapshot read. Snapshot delivery itself uses a rendezvous handoff rather than `flowOn`’s implicit buffer. The Flow performs its initial snapshot immediately, then debounce-coalesces committed changes into bounded replacement snapshots. #### Kotlin type-inference extensions for JPA The `arc-spring-data-jpa` artifact ships Kotlin extension functions on `JpaObservableQuery` that infer the entity type from the generic parameter. Import them by name from `io.cratis.arc.springdata.jpa`, for example `import io.cratis.arc.springdata.jpa.observe`: ```kotlin @JvmStatic fun observeTasks( @FromServices queries: JpaObservableQuery ): Flow> = queries.observe() @JvmStatic fun observeTask( id: String, @FromServices queries: JpaObservableQuery ): Flow = queries.observeById(id) ``` A custom snapshot query can be supplied as a trailing lambda: ```kotlin fun observeActiveTasks( @FromServices queries: JpaObservableQuery ): Flow> = queries.observe( query = JpaSnapshotQuery { em -> em.createQuery( "select t from TaskView t where t.active = true", TaskView::class.java ).resultList } ) ``` The same extensions cover `observeList`, `observeSingle`, `observeById`, and `observeShared`. **Limitation:** extensions cannot be placed on Spring Data’s `JpaRepository` or any other foreign interface. All `observe*` extensions are on Arc’s own `JpaObservableQuery`. #### Java convenience access for JPA Kotlin extensions are invisible from Java. The `JpaObservations` object provides `@JvmStatic` helpers for the two entry points that have no default query in the base API: * `JpaObservations.observePublisher(queries, entityType)` — returns a demand-aware `Flow.Publisher>` using the default select-all JPQL query. * `JpaObservations.observeShared(scope, queries, entityType)` — returns a `SharedFlow>` that replays the latest snapshot while subscribers exist. Both helpers supply the same default JPQL query (`select entity from entity`) as the Kotlin extensions. For a custom snapshot query or callback access, call the `@JvmOverloads` methods on `JpaObservableQuery` directly. ## Current public-seam boundary The integrations deliberately use public Arc and Spring contracts. Contextual command read-model parameters, deterministic ownership, generated `QueryRequest`/`QueryContext` injection, exact Spring Data Commons `Pageable`/`Sort` parameters, and exact `Page` response normalization are implemented. The store-specific request and page adapters remain compatibility utilities rather than request-scoped beans. Repository injection remains ordinary Spring dependency injection and is not automatically tenant-routed by Arc. JPA observable snapshots still use their configured entity-manager factory; tenant labels on notifications are not storage isolation. Mongo observable paths use `MongoOperationsResolver`, including the certified adapter when a tenant-aware resolver is supplied. Imperative transaction scopes remain explicit fixed-store opt-ins and are not coroutine-safe. # Repositories in queries and commands > Translate Arc paging and sorting, resolve current state, reach tenant-bound storage, and enroll a command in a transaction. ## Consume paging and sorting Arc carries zero-based paging and one sort field in `QueryRequest`. A model-bound query can declare the exact non-null Spring Data Commons `Pageable` and `Sort` types. Generated performers create both values directly from the captured query request; they do not use request scope, thread-local state, or a request-scoped bean. * Java ```java public static Page all( Pageable pageable, Sort sort, @FromServices TaskViewRepository tasks ) { if (!pageable.getSort().equals(sort)) { throw new IllegalStateException("Paging and sorting must agree."); } return tasks.findAll(pageable); } ``` * Kotlin ```kotlin @JvmStatic fun all( pageable: Pageable, sort: Sort, @FromServices tasks: TaskViewRepository ): Page { check(pageable.sort == sort) { "Paging and sorting must agree." } return tasks.findAll(pageable) } ``` A page size of zero becomes `Pageable.unpaged(sort)`. A blank sort field becomes `Sort.unsorted()`. Exact Spring Data `Page` returns are normalized by the generated performer before Arc renderers run, preserving `content`, zero-based page number, and the repository’s pre-page `totalElements`; an unpaged result reports Arc page size zero, matching the compatibility adapters. `Pageable` marks paging and sorting capabilities; `Sort` marks sorting capability. A query using either host adapter must return exact `Page` so Arc cannot page or sort an already provider-shaped collection again. Client parameters cannot use the reserved names `page`, `pageSize`, `sortBy`, or `sortDirection`. These explicit generated descriptor flags control TypeScript and OpenAPI paging and sorting surfaces. Host-adapter parameters remain in declaration-order metadata for invocation and validation indexes, but Spring binding, Jakarta model-graph validation, TypeScript, OpenAPI, and endpoint introspection expose only client parameters. The existing `JpaQueryRequestAdapter`, `JpaQueryPageAdapter`, `MongoQueryRequestAdapter`, and `MongoQueryPageAdapter` APIs remain available for manually implemented performers and compatibility. They use the same Spring Data Commons semantics as generated performers. ## Resolve current state for a command The JPA and MongoDB integrations contribute storage-neutral `CanResolveReadModelForCommand` providers. A generated handler can therefore request its current read model directly; Arc uses the command’s generated key and captured tenant context before invoking the handler: * Java ```java @Command public record RenameTask(@CommandKey String id, String title) { public void handle(TaskView current) { // current came from the owning persistence provider using id. } } ``` * Kotlin ```kotlin @Command data class RenameTask(@CommandKey val id: String, val title: String) { fun handle(current: TaskView) { // current came from the owning persistence provider using id. } } ``` Only exact mapped types carrying `@ReadModel` are claimed. JPA uses `DECLARED` ownership because an entity mapping explicitly selects that store. MongoDB uses `FALLBACK`, so a declaring JPA, Chronicle, or application provider wins. Equal-strength claims fail startup rather than selecting a store by bean order. The supplied command key must already be an instance of the persistence identifier type; contextual providers do not recompute or coerce it. Registry-owned read-model parameters are resolved before ordinary Spring services, so a bean of the model class cannot bypass tenant or key selection. For an owned type, a missing row supplies `null` to Kotlin `T?` and `Optional.empty()` to an ordinary Java `Optional`. A missing command key or missing required owned row produces one `dependencyUnavailable` validation result and HTTP 400. Resolver/storage failures and missing ordinary Spring services remain exceptions. Replacing Arc’s `ReadModelForCommandResolverRegistry` bean is an expert override: the application then owns equivalent arbitration and tenancy guarantees. The historical `JpaCommandReadModelResolver` and `MongoCommandReadModelResolver` APIs remain for fixed-store compatibility. Auto-configuration publishes them only with a verified fixed store and optional tenancy. They recompute a key without `CommandContext` and must not be used for tenant routing. ### Route JPA by tenant Optional, single-unit applications receive a fixed `JpaPersistenceUnitResolver`. Tenant-routed applications provide one resolver that reports its exact type union and returns a `JpaPersistenceUnit` certified for the requested `tenantId` and `tenantNamespace`. The unit pairs its `EntityManagerFactory` with an optional matching `JpaTransactionManager`; Arc verifies both the certificate and mapped type before lookup. With `cratis.arc.tenancy.required=true`, mapped and resolver-owned JPA read-model claims must match, and the contextual provider must enforce the exact application resolver. ### Route MongoDB by tenant A fixed `MongoOperationsResolver` serves only commands and observations without a tenant; supplying a tenant fails rather than reading and relabeling the default database. Tenant-routed applications provide one `TenantAwareMongoOperationsResolver`; each lookup returns `TenantMongoOperations`, which certifies the exact tenant identifier together with its isolated `MongoOperations`. Unknown tenants must throw, and Arc rejects mismatched certificates without retrying a default database. Required tenancy validates that command lookup, snapshots, and change streams use adapters for that exact resolver, with no unrelated plain resolver. ## Inject tenant-bound MongoDB access When a tenant-aware resolver is configured, Arc auto-configures a `TenantContextMongoAccess` bean that handlers can inject. It resolves the correct tenant’s `MongoOperations` at the moment each method is called — the tenant is never captured at construction time. This mirrors the `IMongoDatabase` and `IMongoCollection` scoped registrations in Arc .NET’s `ServiceCollectionExtensions.AddCratisMongoDB` (`Source/DotNET/MongoDB/ServiceCollectionExtensions.cs`). ### Ambient-tenant call paths `operations()` and `collection()` read the ambient tenant on every call, so call them inside a `withTenant` scope or any coroutine whose context carries a `TenantCoroutineContext`. Java reaches the same tenant through `operationsForCurrentTenant()` and `collectionForCurrentTenant()` inside `TenantContextBridge.withTenant`. Arc’s command pipeline does not establish this scope for you: a handler that calls them with no scope fails with `IllegalStateException` unless a [fallback tenant](#no-tenant-fallback) is configured. `TenantContextBridge.withTenant` blocks its calling thread, so it is for plain Java threads; inside a Java command handler, which Arc already runs in a coroutine, use the explicit-tenant overload below instead. * Java ```java static void archiveTask(TenantId tenantId, String id, TenantContextMongoAccess access) { TenantContextBridge.withTenant(tenantId, () -> { // operationsForCurrentTenant() reads the tenant the bridge established for this call. MongoOperations ops = access.operationsForCurrentTenant(); ops.remove(Query.query(Criteria.where("_id").is(id)), TaskDocument.class); }); } ``` * Kotlin ```kotlin suspend fun archiveTask(tenantId: TenantId, id: String, access: TenantContextMongoAccess) { withTenant(tenantId) { // operations() reads currentTenant() from the coroutine context on every call. val ops = access.operations() ops.remove(Query.query(Criteria.where("_id").`is`(id)), TaskDocument::class.java) } } ``` For direct native driver access, the collection accessors return a `MongoCollection` named by the `NamingPolicy`: * Java ```java static void deleteTask(TenantId tenantId, String id, TenantContextMongoAccess access) { TenantContextBridge.withTenant(tenantId, () -> { MongoCollection col = access.collectionForCurrentTenant(TaskDocument.class); col.deleteOne(Filters.eq("_id", id)); }); } ``` * Kotlin ```kotlin suspend fun deleteTask(tenantId: TenantId, id: String, access: TenantContextMongoAccess) { withTenant(tenantId) { val col = access.collection(TaskDocument::class.java) col.deleteOne(Filters.eq("_id", id)) } } ``` The collection name comes from `NamingPolicy.getReadModelName()` (kernel-aligned English pluralization), not from Spring Data’s `@Document` annotation. For Spring Data-mapped types, prefer `operations()` and its type-safe CRUD methods, which respect `@Document` and the configured object mapping. ### Explicit-tenant call paths When the tenant is already in hand, pass it explicitly using the overload that bypasses the ambient context: * Java ```java MongoOperations ops = access.operations(TenantId.of(tenantId)); ``` * Kotlin ```kotlin val ops = access.operations(TenantId.of(tenantId)) ``` This overload is callable from both Kotlin and Java without any context setup. ### No-tenant fallback When no tenant is present in the current context and no fallback tenant is configured, all resolution methods throw `IllegalStateException`. Construct `TenantContextMongoAccess` with an explicit `fallbackTenantId` when the application has a default tenant for non-tenanted code paths: * Java ```java @Bean public TenantContextMongoAccess arcTenantContextMongoAccess( TenantAwareMongoOperationsResolver resolver, NamingPolicy namingPolicy ) { return new TenantContextMongoAccess(resolver, namingPolicy, TenantId.DEFAULT); } ``` * Kotlin ```kotlin // Application bean override — use the workspace default tenant when no tenant is active. @Bean fun arcTenantContextMongoAccess( resolver: TenantAwareMongoOperationsResolver, namingPolicy: NamingPolicy ): TenantContextMongoAccess = TenantContextMongoAccess(resolver, namingPolicy, TenantId.DEFAULT) ``` The auto-configured bean uses no fallback (throw on missing tenant). Override it as above when a fallback is needed. ### Real-server coverage `TenantContextMongoAccessTests` and `JavaTenantContextMongoAccessTests` verify per-call tenant resolution and isolation against the `mongo-java-server` 1.47.0 `MemoryBackend` emulator. The `mongoReplicaSetTest` gate additionally proves the following against a real pinned MongoDB 8.2 single-node replica set (`mongo:8.2@sha256:e0ce8c35124d4a9f9785532d1f268f39e9728ffa1cb38f46fa482436424c4bd3`): * `TenantContextMongoAccess` routes both coroutine (`withTenant`) and Java blocking (`TenantContextBridge`) calls to genuinely isolated real MongoDB databases for two different tenants. * The `DefaultNamingPolicy` kernel-aligned correction (`Person` → `People`) routes writes to the correct physical collection on a real server; collection `Persons` is never created. * A change-stream cursor opened against a real replica set receives an `INSERT` event. Change streams require replica-set mode and cannot be verified against the MemoryBackend emulator. * Kotlin data class and Java record concept IDs (UUID binary subtype 4, string, int64) persist with correct BSON types and round-trip on a real server; null `@Field(ALWAYS)` fields appear as BSON null; malformed stored scalars fail visibly through the reading converter. The gate does not cover replica-set failover, TLS, transactions through `TenantContextMongoAccess`, or deterministic resume/reconnect under cursor interruption. Those limitations remain. ## Enroll commands in transactions Imperative Spring transactions are disabled by default because JPA and MongoDB transaction managers bind resources to a thread while Arc command handlers may suspend and resume on another worker. Fixed-store applications can explicitly opt in: ```yaml cratis: arc: spring-data: jpa: command-transactions-enabled: true mongodb: command-transactions-enabled: true ``` Opt-in succeeds only for a verified fixed store with an identity-aligned transaction manager. Dynamic tenant resolvers never receive a fixed transaction scope. Even when enabled, command code must not suspend or execute persistence work on another thread; the scope is imperative, not coroutine-safe. The scopes commit only a successful final `CommandResult` and roll back failures when completion remains on the opening thread. There is no distributed transaction across JPA, MongoDB, and Chronicle. Chronicle now completes after the local scopes, so a JPA or MongoDB completion failure prevents the append. Partial outcomes remain possible in the other direction: MongoDB may commit before JPA fails, and both local stores may commit before Chronicle fails or has an indeterminate external outcome. Imperative coroutine affinity and cross-store reconciliation remain explicit limitations. # Test commands and queries in process > Exercise generated Arc artifacts through real pipelines without starting Spring Boot. ## Add the testing module ```kotlin dependencies { testImplementation("io.cratis:arc-testing:") } ``` `arc-testing` uses the real command, one-shot query, and observable-query pipelines. It does not start Spring Boot or replace generated behavior with fake handlers. This is in-process testing for the Spring Boot product, not a supported non-Spring application host or a JVM Screenplay implementation; Screenplay is not planned. `CommandScenario` and `QueryScenario` enable JSON round trips by default; `ObservableQueryScenario` exercises the pipeline without those opening-argument or result-data JSON round trips. Its default emission guards independently reconstruct [supported arguments per dispatch](/arc/backend/kotlin/guides/queries/#bound-emission-guard-arguments), using an Arc mapper with the scenario’s actual derived-type registry for concrete scalar concepts. That does not enable polymorphic argument cloning; unsupported guarded shapes terminate unauthorized before any guard runs. `CommandScenario` and `QueryScenario` register the supplied module’s `derivedTypes` in a scenario-local Arc JSON registry before reading values. Command properties and nested query data declared as registered interfaces or base classes therefore retain their concrete types through the round trip. Registrations do not leak between scenarios; a manual handler or performer alone contributes no derived-type registrations. For direct query values and list entries, `QueryScenario` uses the descriptor’s declared registered base type rather than reading an annotated derivative as its runtime class. Unknown or missing `_derivedTypeId` values still fail; scenarios do not infer registrations or silently accept unknown identifiers. This is not an arbitrary generic graph-cloning API: erased generic roots, undeclared polymorphic roots, and other container shapes retain the existing runtime-class round-trip limitations. Ordinary model properties use their Jackson-declared types. Serialization failures propagate to the test rather than becoming successful pipeline results. ## Test a command * Java ```java static void createsATask(ArcArtifactModule module, TaskRepository repository) throws Exception { CommandScenario configured = new CommandScenario<>(module, CreateTask.class) .addService(TaskRepository.class, repository); try (BlockingCommandScenario scenario = new BlockingCommandScenario<>(configured)) { TaskCreated response = scenario.execute(new CreateTask("Try Arc")) .shouldSucceed() .shouldHaveResponse(TaskCreated.class); } } ``` * Kotlin ```kotlin suspend fun createsATask(module: ArcArtifactModule, repository: TaskRepository) { val result = CommandScenario(module, CreateTask::class.java) .addService(TaskRepository::class.java, repository) .execute(CreateTask("Try Arc")) result.shouldSucceed().shouldHaveResponse(TaskCreated::class.java) } ``` Select queries with `FullyQualifiedQueryName` and use `QueryScenario`. Add services, validators, policies, filters, renderers, read-model interceptors, a principal, tenant, correlation ID, paging, or sorting through scenario methods. Disable command or one-shot query serialization round trips only when the test intentionally bypasses the wire boundary. All three scenario classes expose `addModelValidator(ModelValidator)`, `addConceptValidator(ConceptValidator)`, and `addConceptExclusion(ConceptValidationExclusion)`, returning the configured scenario. They compose with existing `addValidator` rules through the real default validation filter, without replacing it. Register Java blocking or asynchronous model adapters on that scenario before constructing its Java bridge. Model rules run on command inputs or supplied query arguments, not services, omitted defaults, or returned data; see [reusable model validation](/arc/backend/kotlin/guides/commands/#reuse-model-validation). Default command/query JSON round trips may turn a shared source identity into separate model instances, so validation follows the prepared graph’s identities. Register [direct concept exclusions](/arc/backend/kotlin/guides/commands/#exclude-a-direct-concept-rule-edge) before creating the existing blocking or asynchronous Java scenario bridge. An exclusion suppresses only concept rules on that owner/member edge, never model rules or other filters. To prove that an ignored-first shared instance still validates on a required edge, use an additional in-process check with command or query argument serialization disabled; JSON duplication alone cannot prove alias handling. Command and query results carry matching positive and negative assertions, so a test can pin which stage rejected an operation instead of only that it failed. `shouldBeAuthorized` and `shouldBeUnauthorized` cover authorization, `shouldBeValid` and `shouldBeInvalid` cover validation feedback, and `shouldHaveErrors` and `shouldHaveNoErrors` cover retained exception messages. Chaining `shouldBeAuthorized().shouldBeInvalid()` states that authorization passed and validation rejected the command, which `shouldFail` alone does not. `CommandScenarioResult.shouldBeInvalid()` requires that validation actually ran. It fails when every validation result has reason `dependencyUnavailable`, because that outcome usually means the scenario forgot to register a read model or another validator dependency and no rule executed. Seed the missing dependency for a rule assertion. When dependency failure is the behavior under test, assert it explicitly with `shouldHaveValidation(reason = ValidationResultReasons.DEPENDENCY_UNAVAILABLE)`; this remains green without pretending a validation rule ran. To exercise the same tenancy contract the Spring Boot integration uses, provide both the resolver and explicit request context. The resolved tenant ID is also used as the namespace unless a namespace is supplied: * Java ```java static void resolvesTheTenant(ArcArtifactModule module) throws Exception { CommandScenario configured = new CommandScenario<>(module, CreateTask.class) .withTenantResolution( new HeaderTenantIdResolver(), new TenantResolutionContext(Map.of("X-Cratis-Tenant-Id", "tenant-one"))); try (BlockingCommandScenario scenario = new BlockingCommandScenario<>(configured)) { scenario.execute(new CreateTask("Try Arc")); } } ``` * Kotlin ```kotlin suspend fun resolvesTheTenant(module: ArcArtifactModule) { val result = CommandScenario(module, CreateTask::class.java) .withTenantResolution( HeaderTenantIdResolver(), TenantResolutionContext(headers = mapOf("X-Cratis-Tenant-Id" to "tenant-one")) ) .execute(CreateTask("Try Arc")) } ``` `QueryScenario` and `ObservableQueryScenario` expose the same `withTenantResolution` method. ## Test observable queries Use `ObservableQueryScenario.collect(maximumEmissions, timeoutMillis)` to collect at most the requested number of emissions through the real observable-query pipeline. One timeout budget covers both opening (including suspending authorization, filters, and performer creation) and collection. Timeout and caller cancellation propagate to the Kotlin caller and cancel cooperative upstream work; they are not successful scenario results. `maximumEmissions` is a cap, not a required count. A finite stream may complete early or empty, and `shouldSucceed()` alone does not require an emission. Chain `shouldHaveEmissionCount(expected)` whenever the test requires an exact count. Reaching the cap stops upstream collection. Add per-emission guards with `addEmissionGuard`; opening rejection is available through `shouldFail()`, while a terminal guard denial is an unauthorized emission checked with `shouldTerminateUnauthorized()`. Like command and one-shot query scenarios, the observable scenario derives its default validation threshold from the selected descriptor: `TreatWarningsAsErrors` makes warnings blocking while allowing information. `withAllowedValidationSeverity` explicitly overrides that default, including `null`, which restores error-only blocking. The observable scenario does not currently provide the command/one-shot argument and result-data JSON round trips; collecting values is not a serialization-contract check. ## Pin a command-side read model A command that takes a read-model parameter normally has it resolved by whichever store owns the type. In a test, pin the read model to a known state instead. No store, query pipeline, or Chronicle kernel is involved, and the pin flows through the same ownership registry a store would use: * Java ```java static void renamesATask(ArcArtifactModule module) throws Exception { CommandScenario configured = new CommandScenario<>(module, RenameTask.class) .withReadModelForKey(TaskView.class, "task-1", new TaskView("task-1", "Current title", 7L)); try (BlockingCommandScenario scenario = new BlockingCommandScenario<>(configured)) { scenario.execute(new RenameTask("task-1", "Renamed title", 7L)); } } ``` * Kotlin ```kotlin suspend fun renamesATask(module: ArcArtifactModule) { val result = CommandScenario(module, RenameTask::class.java) .withReadModelForKey(TaskView::class.java, "task-1", TaskView("task-1", "Current title", 7)) .execute(RenameTask("task-1", "Renamed title", 7)) } ``` `withReadModelForKey` restricts the pin to one command key, so the test also proves the command carries the key it should; any other key resolves to absence. Keys are compared after unwrapping `ConceptAs` wrappers, so a scalar pin matches a concept-typed command key. Use `withReadModel(type, readModel)` when the key is not the point of the test. Pinning `null` pins deliberate absence, which is how the missing-model path is exercised — a required parameter fails with `dependencyUnavailable`, while a Kotlin nullable parameter observes `null` and a Java `Optional` parameter observes `Optional.empty()`: * Java ```java static void rejectsAMissingTask(ArcArtifactModule module) throws Exception { CommandScenario configured = new CommandScenario<>(module, RenameTask.class) .withReadModel(TaskView.class, null); try (BlockingCommandScenario scenario = new BlockingCommandScenario<>(configured)) { scenario.execute(new RenameTask("missing", "Renamed title", 0L)) .shouldHaveValidation(null, null, ValidationResultReasons.DEPENDENCY_UNAVAILABLE); } } ``` * Kotlin ```kotlin suspend fun rejectsAMissingTask(module: ArcArtifactModule) { val result = CommandScenario(module, RenameTask::class.java) .withReadModel(TaskView::class.java, null) .execute(RenameTask("missing", "Renamed title", 0)) result.shouldHaveValidation(reason = ValidationResultReasons.DEPENDENCY_UNAVAILABLE) } ``` Pins claim declared ownership, so they win over a fallback resolver. A read-model type can be pinned once without a key or once per key, not both, and `addReadModelResolver` remains available for a resolver that has to compute something. ## Test Chronicle commands without a kernel Add both test support and the Chronicle starter. The starter registers its `CommandScenarioExtender` through `ServiceLoader`, so a `CommandScenario` automatically gets an in-memory Chronicle event log without starting Spring Boot or a Chronicle kernel. * Java ```java void registersACustomer() { CommandScenario configured = new CommandScenario<>(module, RegisterCustomer.class); ChronicleCommandScenario chronicle = ChronicleCommandScenarios.chronicle(configured); chronicle.given().events(customerId, new CustomerRegistered("Existing")); try (BlockingCommandScenario scenario = new BlockingCommandScenario<>(configured)) { scenario.execute(new RegisterCustomer(customerId, "Ada")).shouldSucceed(); } chronicle.shouldHaveAppendedEvent(customerId, CustomerRegistered.class); } ``` * Kotlin ```kotlin suspend fun registersACustomer() { val scenario = CommandScenario(module, RegisterCustomer::class.java) scenario.givenChronicle() .events(customerId, CustomerRegistered("Existing")) val result = scenario.execute(RegisterCustomer(customerId, "Ada")) result.shouldSucceed() scenario.chronicle() .shouldHaveAppendedEvent(customerId, CustomerRegistered::class.java) } ``` Given events establish ordered event-source history and are excluded from `appendedEvents`. Arrange deterministic append rejection with `constraintViolation(...)` or `concurrencyViolation(...)`, then assert the machine-readable result with `shouldHaveConstraintViolation(...)` or `shouldHaveConcurrencyViolation(...)`. A rejected append does not appear in `appendedEvents`. ## Test with Java The Java tabs above use `BlockingCommandScenario`, a closeable bridge over a configured `CommandScenario`, for ordinary JUnit tests. Read-model pins, tenant resolution and Chronicle setup are configured on that scenario before the bridge is constructed, and Chronicle scenarios reach the Kotlin extension functions through the generated `ChronicleCommandScenarios` static methods. For asynchronous tests, construct `AsyncCommandScenario`, `AsyncQueryScenario`, or `AsyncObservableQueryScenario` with a `JavaAsyncScope`. No Kotlin coroutine imports are needed. Command and one-shot query bridges accept a configured scenario, a manual handler/performer, or a module plus the command class/query name. The observable bridge accepts a configured `ObservableQueryScenario`. ```java ExecutorService executor = Executors.newSingleThreadExecutor(); try (JavaAsyncScope owner = JavaAsyncScope.owningExecutorService(executor)) { AsyncCommandScenario commands = new AsyncCommandScenario<>(configured, owner); commands.execute(new CreateTask("Try Arc")) .toCompletableFuture().get(5, TimeUnit.SECONDS).shouldSucceed(); } ``` Use `JavaAsyncScope.usingExecutor(executor)` to borrow an executor without shutting it down, or `owningExecutorService(executor)` to transfer shutdown responsibility. All three asynchronous scenarios **borrow** the owner: they neither implement `AutoCloseable` nor close it. Close the owner explicitly after the test. Closing it cancels its Core and Testing operations; cancellation cleans up cooperative upstream work, not arbitrary blocking code. `execute`, `validate`, and `perform` return `CompletionStage`. `AsyncObservableQueryScenario.collectAsync(maximumEmissions)` also returns a `CompletionStage>`; overloads accept a timeout (default 5,000 milliseconds), arguments, paging, sorting, and transfer mode (default `FULL`). The same opening-and-collection timeout budget and emission cap apply. Timeout and cancellation cancel the stage rather than returning a successful result. Canceling `toCompletableFuture()` cancels only that child operation, not the owner or siblings. Stages can complete before coroutine cleanup has finished; tests that require cleanup must observe the upstream cleanup signal too. Supply an executor with nonblocking submission for prompt asynchronous return. Direct or inlining executors can execute on the calling thread; a blocking executor or blocking user operation is not covered by an unconditional concurrency or interruption promise. Existing scenario instances return canceled stages or inert callback handles after owner close without executing user code. Constructing a scenario with an already-closed owner is allowed, but Core facade factories such as `owner.commands(...)` still reject a closed owner synchronously. The existing `collect(...)` method remains callback/handle-based and returns `ObservableQueryScenarioHandle`; `cancel()` cancels upstream work. Cancellation, including internal timeout, invokes neither callback. A success-callback exception is passed to the failure callback on the same coroutine; a failure-callback exception escapes that coroutine. Do not use callback delivery alone as a timeout completion signal; use `collectAsync` when stage termination is needed. The existing `CoroutineScope` constructors and Kotlin default calls remain available. Adding owner overloads can make an untyped `null` scope argument ambiguous in Java; a null owner/scope is not supported. For Kotlin integration modules, `JavaAsyncScope.launchStage`, `JavaAsyncScope.launch`, and `CoroutineScope.launchStage` (in `io.cratis.arc.java`, JVM holder `CoroutineCompletionStages`) are supported public Kotlin/JVM SPI. Their `@JvmSynthetic` methods are hidden from Java source, not reflection. The shared stage helper catches ordinary `Exception` failures into the stage without failing an ordinary parent job; other throwables retain coroutine parent-failure semantics. These are JVM-specific bridges, not an Arc .NET parity claim. ## Use manual artifacts only for framework tests Scenario constructors also accept manual `CommandHandler` and `QueryPerformer` instances. Application tests should select generated artifacts from an `ArcArtifactModule` so KSP wiring remains under test. # Generate TypeScript proxies > Configure the Arc Gradle plugin to generate command, one-shot query, observable query, model, interface, and enum proxies. ## Share model validation [Fluent validation](/arc/backend/kotlin/guides/validation/) contributes verified declarations to the existing renderer. Active models export `Validator`; command and supported RFC QUERY validators compose nested shared rules, including indexed siblings. Structurally equal duplicate model descriptors merge validation conjunctions; incompatible structures reject. Annotation-only output and its differential boundary are unchanged. Manual shared-generation CLI calls require `--module-name` and the complete compiled root/dependency classpath, not only manifest resources. Follow the [generated excerpt and handwritten caller](/arc/backend/kotlin/guides/validation/#display-generated-client-feedback), or consult the [dependency extraction and root inventory contract](/arc/backend/kotlin/reference/validation/#manual-build-tooling). ## Configure generation Use the `io.cratis.arc` plugin and set an output directory. Route configuration exists on both sides — build-time (`cratisArc.endpoints.*`) and runtime (`cratis.arc.endpoints.*`) — and both sides must agree. The Arc Spring Boot starter enforces this automatically. The plugin’s `writeArcEndpointOptionsResource` task packages the build-time settings as `META-INF/arc/endpoint-options.json` on the main resources, whether or not proxy generation runs. At startup the starter reads that resource and fails with an error that names the disagreeing setting and both values if they differ. The check is skipped only when `META-INF/arc/endpoint-options.json` is absent from the application classpath. Builds without the Arc Gradle plugin can package the same resource through the proxy CLI’s `--endpoint-options-output` option, described below. > **Note on similar-looking settings:** `cratisArc.proxies.segmentsToSkip` and `cratisArc.endpoints.segmentsToSkip` are two different settings. `proxies.segmentsToSkip` controls how many leading package segments are stripped when deciding the *file path* of a generated TypeScript file. `endpoints.segmentsToSkip` controls how many leading package segments are stripped when computing the *HTTP route* the generated client calls. The runtime counterpart is `cratis.arc.endpoints.segments-to-skip-for-route`. Mixing them up produces the wrong file layout or the wrong route, not a startup error. ```kotlin plugins { id("io.cratis.arc") version "" } cratisArc { moduleName.set("Orders") dependencyVersion.set("") manageDependencies.set(true) endpoints { routePrefix.set("api") segmentsToSkip.set(2) includeCommandNames.set(true) includeQueryNames.set(true) enableQueryHttpMethod.set(true) } proxies { enabled.set(true) outputDirectory.set(layout.projectDirectory.dir("ClientApp/src/api")) removeStaleGeneratedFiles.set(true) segmentsToSkip.set(2) } } ``` ## Share types with an existing npm package A type can be answered by configuration instead of being generated, so a model already published in a TypeScript package is imported rather than duplicated. ```kotlin proxies { // A JVM type that should cross the wire as a plain TypeScript type. mapType("java.time.Duration", "string") // A JVM type that is imported from an npm package. mapType("io.acme.shared.Money", "Money", "@acme/models") // Every model type under a JVM package, imported by its simple name. mapPackage("io.acme.shared.model", "@acme/models") } ``` Type mappings are consulted **ahead of** the generator’s built-in type map, so an entry can correct an existing mapping as well as declare one the generator has never seen. Concepts are unwrapped first, so map the concept’s underlying type rather than the concept. A mapped type is **not generated**. Emitting it as well would put a local declaration and an external import of the same name in scope. For a package mapping the longest matching JVM package wins, so a nested package can override a broader one. Unpackaged overrides support `string`, `number`, `boolean`, `object`, and `Date`, with their actual JavaScript runtime constructors (`String`, `Number`, `Boolean`, `Object`, and `Date`). Packaged mappings require a named export. Manifest interfaces use type-only imports and `Object` descriptors; manifest numeric enums use type-only imports and `Number` descriptors. Other mapped types must export a constructible class with fields registered using Fundamentals `@field` decorators. A plain, undecorated class is not a hydration contract: its payload fields may be lost. Mappings change client generation, not server serialization; the package must honor the existing JSON wire shape. A missing or blank mapping component is named in a build warning and skipped. Nonblank but unsafe or unrepresentable mappings, import collisions, and overrides of command or concept declarations fail before output is written. Map the properties/response of a command or the underlying value of a concept instead. Arbitrary TypeScript expressions, unions, arrays in a type name, and ambient undeclared constructors are not supported. Map values only support primitive overrides; external map-value hydration and externally mapped polymorphic bases/derivatives are not supported. The standalone CLI takes the same values as repeatable `--type-to-typescript =[=]` and `--package-to-npm =` options. The value is split on at most two separators. Anything after the second stays in the package field and is validated as part of its import path; it is never silently discarded. Neither the exported identifier nor the npm import path may contain an `=`. ## Run the task ```bash ./gradlew generateArcProxies ``` The task is also attached to `build`. It reads `META-INF/cratis/arc/*.json` manifests from main output and classpaths, rewrites only changed content, and deletes only stale files bearing Arc’s generated marker. The `io.cratis.arc` plugin also registers a `writeArcEndpointOptionsResource` task that writes `META-INF/arc/endpoint-options.json` to the main source set resources. The Spring Boot starter reads this file at startup and fails fast when any setting disagrees with the runtime configuration. Both routes — the Gradle task and the standalone CLI — delegate to the same serialiser, so their output is byte-identical for the same options. ```mermaid graph LR Models[Kotlin and Java models] --> KSP[KSP] KSP --> Manifest[Arc artifact manifest] Manifest --> Task[generateArcProxies] Task --> TS[Command, query, model, and enum TypeScript] TS --> Packages[@cratis/arc, @cratis/arc.react, @cratis/fundamentals] Task --> Options[META-INF/arc/endpoint-options.json] Options --> Check[Spring Boot startup consistency check] ``` ## Use the standalone CLI Projects that invoke the proxy generator directly through `io.cratis.arc.gradle.GenerateArcProxiesCli` instead of applying the `io.cratis.arc` Gradle plugin must pass `--endpoint-options-output ` to activate the same Spring Boot startup consistency check. Without the flag the CLI behaves exactly as before and no resource is written. ```kotlin // build.gradle.kts — JavaExec task that calls the CLI directly val generateArcProxies by tasks.registering(JavaExec::class) { classpath = arcProxyGenerator mainClass.set("io.cratis.arc.gradle.GenerateArcProxiesCli") args( "--manifest-classpath", ..., "--output-directory", ..., "--route-prefix", "api", "--route-segments-to-skip", "5", "--proxy-segments-to-skip", "5", // Activates the startup consistency check for projects that don't use the Gradle plugin. "--endpoint-options-output", layout.buildDirectory.dir("generated/arc-endpoint-options/main").get().asFile.absolutePath ) } ``` The output directory must be on the application runtime classpath. When the project uses `processResources` to copy resources, add it as a resource source directory and make `processResources` depend on the generation task: ```kotlin sourceSets.main.get().resources.srcDir(layout.buildDirectory.dir("generated/arc-endpoint-options/main")) tasks.named("processResources") { dependsOn(generateArcProxies) } ``` This is safe only when the CLI task does not itself depend on `processResources`. If it does (because it depends on `classes`, which includes `processResources`), target `build/resources/main` directly and make the JAR assembly task depend on the generation task instead. All five endpoint-option flags mirror the Gradle extension settings: | CLI flag | Extension setting | Default | | --- | --- | --- | | `--route-prefix` | `endpoints.routePrefix` | `api` | | `--route-segments-to-skip` | `endpoints.segmentsToSkip` | `0` | | `--include-command-names` | `endpoints.includeCommandNames` | `true` | | `--include-query-names` | `endpoints.includeQueryNames` | `true` | | `--enable-query-http-method` | `endpoints.enableQueryHttpMethod` | `true` | ## Generate observable clients A manifest query with `OBSERVABLE` transport generates an `ObservableQueryFor` class. Enumerable queries include React hooks for observable snapshots, suspense, paging, sorting, and change streams; single-model queries include observable and suspense hooks. Generated `when` helpers conditionally subscribe, and client query parameters retain validation and HTTP preference metadata. Infrastructure-owned service, `QueryRequest`, and `QueryContext` parameters are omitted from one-shot and observable parameter interfaces, descriptors, required-argument lists, properties, validators, sorting helpers, React hooks, and imports. The generated clients use the Spring host’s direct or multiplexed observable transports provided by `@cratis/arc`. See [Expose one-shot and observable queries](/arc/backend/kotlin/guides/queries/) for the HTTP, SSE, and WebSocket contracts. ## Sort by returned-row fields For enumerable queries whose metadata advertises sorting, `sortBy` helpers name properties of the **returned model**, including class-base properties already represented in the manifest. Request-only arguments such as `filter` do not become sort keys. Parameterless queries can therefore expose useful row-field helpers too. Both one-shot and observable clients follow this rule. This does not grant new paging or sorting capabilities, infer database indexes, or guarantee that every field is sortable by a custom provider. The renderer/provider still determines which keys it supports; use an explicit `Sorting` value for provider-specific keys not present in result metadata. Scalar and empty result models have no named field helpers. Interface inheritance not represented in the manifest is not invented by the generator. Regenerate clients when upgrading: helpers for request-only arguments disappear, and the helper’s old public `query` owner reference is no longer emitted, allowing a result field named `query` to have its own sorting helper. Private helper storage is collision-safe for fields such as `_name` and `name`, including inherited fields. A member named `constructor` cannot be emitted as a class accessor and fails generation with an actionable error; expose a different result contract for that case. Arc .NET at `v22.14.0` also derives model-bound helper names from method parameters; using result fields here is a deliberate JVM correctness divergence, not matching raw .NET output. ## Use JVM temporal and UUID types Direct properties and the underlying values of Kotlin or Java `ConceptAs` use the same generated TypeScript mapping. Concept wrappers remain strongly typed on the server but are erased to the mapped client type. | JVM type or concept value | Generated TypeScript type | Runtime constructor | | --- | --- | --- | | `String` | `string` | `String` | | `Int` / `Integer` | `number` | `Number` | | `UUID` | `Guid` from `@cratis/fundamentals` | `Guid` | | `LocalDate` | `DateOnly` from `@cratis/fundamentals` | `DateOnly` | | `LocalTime` | `TimeOnly` from `@cratis/fundamentals` | `TimeOnly` | | `LocalDateTime` | `Date` | `Date` | | `Instant` | `Date` | `Date` | | `OffsetDateTime` | `Date` | `Date` | | `ZonedDateTime` | `Date` | `Date` | | `OffsetTime` | `string` | `String` | | `Duration` | `string` containing the ISO-8601 duration | `String` | | `Period` | `string` containing the ISO-8601 period | `String` | | Arc enum | generated enum | `Number` | `UUID` intentionally maps to `Guid`, rather than `string`, for .NET proxy parity. Arc disables Jackson’s `WRITE_DURATIONS_AS_TIMESTAMPS` in both its Core mapper and Spring configuration, so `Duration` serializes and deserializes as ISO-8601 text. Generated TypeScript therefore remains `string`, and OpenAPI describes it as `string`/`duration`. It is not mapped to Fundamentals `TimeSpan`: Java `Duration` and C# `TimeSpan` use different wire formats. The current tested `Period` contract is narrower: the Core mapper round-trips ISO-8601 text and the focused generator test maps it to TypeScript `string`. `OffsetTime` is deliberately textual and untyped in generated TypeScript: its offset remains in the wire string, but offset-specific semantics are not hydrated into a class. The generated TypeScript API change is source-breaking. Existing consumers must replace JavaScript `Date` values for `LocalDate`, string values for `LocalTime`, and UUID string values with the mapped classes: ```typescript import { DateOnly, Guid, TimeOnly } from "@cratis/fundamentals"; import { EchoCalendar } from "./generated/runtime/index.js"; const command = new EchoCalendar(); command.identifier = Guid.parse("11111111-1111-1111-1111-111111111111"); command.date = DateOnly.from(2026, 1, 1); command.time = TimeOnly.from(14, 30, 45, 123); ``` Commands serialize `Guid`, `DateOnly`, and `TimeOnly` as scalar JSON strings. Generated GET query parameters serialize them as scalar URL values. Returned command responses and query data hydrate generated models and these fields into real class instances rather than leaving plain objects. The pinned `TimeOnly` client retains milliseconds only. The runtime gate observes raw `08:09:10.1235567` from the JVM and hydration as `08:09:10.123`; because rounding would produce `.124`, this proves truncation rather than rounding. It is not an exact precision round-trip. Arc accepts and emits `LocalTime` values with up to seven fractional digits for 100 ns compatibility. Deserialization rejects eight or nine fractional digits as malformed, and serialization rejects values finer than 100 ns rather than rounding or truncating them. That server binding is distinct from a remaining limitation in the shared `@cratis/arc` generated client. Its explicit QUERY path passes `DateOnly` and `TimeOnly` component objects to native `JSON.stringify`, because those classes do not provide `toJSON()`, instead of invoking their typed scalar serializer. Use generated GET queries for those parameters until upstream serialization uses the typed serializer or `toJSON()`. `Guid` is unaffected because it provides `toJSON()`. The JVM server does not accept the client library’s wrong component-object shape. `LocalDateTime`, `Instant`, `OffsetDateTime`, and `ZonedDateTime` remain JavaScript `Date`. A `LocalDateTime` has no zone, so constructing a JavaScript instant invents one. `OffsetDateTime` and `ZonedDateTime` can preserve the represented instant but not their original offset or zone identity. Use an application-owned representation when those distinctions are part of the domain contract. Concept wrapper classes are not emitted or imported into the client. Imports are retained for generated underlying enums and model classes when Arc needs them for typing or runtime result construction. Type-only generated interfaces use `import type` where appropriate, and the strict contract enables `verbatimModuleSyntax` so accidental runtime imports fail compilation. The OpenAPI starter continues to describe `UUID`, `LocalDate`, and `LocalTime` as `string`/`uuid`, `string`/`date`, and `string`/`time`; the richer classes are generated-client types, not new wire shapes. ## Discover identity details KSP automatically exports the details graph of public concrete source providers implementing `IdentityDetailsProvider` or `AsyncIdentityDetailsProvider`. It also reads public typed factory returns: Kotlin top-level, member and companion functions, and ordinary Java static or instance methods. Spring `@Bean` is not required. The Kotlin sample’s anonymous provider is discoverable from its factory’s `IdentityDetailsProvider` return type; KSP does not inspect the object body, evaluate `detailsType`, instantiate providers, or register runtime beans. Inherited generic bindings are resolved through provider base classes. Abstract generic provider bases, including implicitly abstract Kotlin sealed classes, remain templates; a concrete provider or factory must establish a supported, public top-level concrete details class with no type parameters. Raw, wildcard/star-projected, `Any`/`Object`, generic, inaccessible and unsupported details shapes fail with `ARCKSP0307` (graph failures also retain their specific model diagnostic). Bind a concrete details type and correct its graph rather than relying on runtime erasure. Existing supported nested property graphs are traversed; this does not broaden DTO shape support. Identity-only compilations emit the same artifact module, service entry and format-8 manifest with empty command/query lists. Roots and graph metadata are rebuilt from current-round source declarations, including generated declarations alongside already valid roots; native Kotlin and Java incremental checks cover correction, addition, removal, inherited generic binding changes and reachable DTO property edits. KSP artifact comparisons use fresh output and project-cache directories and require KSP to execute; proxy assertions separately check the changed roots and fields. `ArcSymbolProcessorIdentityCompilationTest`, `ArcIdentityDiscoveryFunctionalTest`, `GeneratedIdentityArtifactsTest`, `IdentityArtifactsJavaContractTest` and `GeneratedTypeScriptProxiesTest` cover this declaration-to-client path. Dependency-only providers are not scanned wholesale. Compile tests demonstrate source bindings through compiled Kotlin and ordinary Java generic provider bases, and a dependency-resident Kotlin data-class DTO with a string property. This is not evidence for arbitrary binary DTOs or Java binary record property discovery. Use the existing `@ExportedType` on a supported source DTO when no public source provider/factory declaration exposes it. Explicit export remains useful for unrelated DTOs, but is unnecessary for the sample’s typed identity factory. This is compile-time discovery, not .NET assembly scanning or bulk library export; no runtime provider registration or raw-output equivalence is implied. ## Include declared body properties Generated command and model metadata preserves public constructor-property order and appends remaining eligible public declared Kotlin properties sorted by name. Backed body `var`, backed `val`, and private-setter `var` state uses the existing shape, key, constraint, summary, and reachable-model machinery. Member `@JsonIgnore` and nonpublic Kotlin state are excluded. A model’s inherited state remains in its separate base model, with declared overrides retained; inherited command state is not established by this change. Computed getters remain available on output-only models, but computed or explicitly read-only Kotlin state in command input graphs fails with `ARCKSP0300`, also when the root is a Java record. Unrepresentable body Jackson renames, write-only access, or split ignore/property annotations likewise fail rather than emitting falsely writable or wrongly named fields. Use default Arc wire names, backed input properties, and separate output models; see [command body state](/arc/backend/kotlin/guides/commands/#declare-body-state-explicitly). Arbitrary application Jackson overrides are not inferred by KSP. This corrects previously missing generated fields, body command keys, and validation; review the resulting client changes. Body initializers are not copied as TypeScript defaults. That body-state correction did not itself change the manifest schema; current [format 8](/arc/backend/kotlin/reference/validation/#manifest-format-8-migration) additionally carries explicit validation-ignore edges. Native contract fixtures assert real Jackson wire names, generated Kotlin and ordinary-Java invocation, manifest/discovery metadata, and TypeScript text; incremental add/remove and invalid-to-valid builds assert byte-stable regenerated artifacts. These checks do not establish broader Arc .NET parity. ## Declare nonnullable sequence elements Command, model, interface, and read-model properties may use nullable outer `List?`, `Collection?`, and `Array?` containers with nonnullable elements. The manifest retains outer nullability and the sequence kind; generated model fields remain optional `T[]` fields. Nullable entries such as `List`, `Collection?`, and `Array?` are unsupported and now fail at the member with `ARCKSP0300`, before an incompatible manifest can reach proxy discovery. Java `List<@Nullable T>` and `List<@CheckForNull T>` entries are explicitly nullable, including record components handled through source fallback. Ordinary unannotated Java platform elements remain accepted; platform types are not explicit nullable declarations. This tightens source acceptance: previously accepted nullable-entry declarations must change to nonnullable elements. It does not independently change the manifest schema, expand collection-interface query support, or relax existing Java array and generic-variance restrictions. ## Use string-keyed map properties Command, model, interface, and read-model properties may use `Map` / `java.util.Map` when every entry is nonnullable and every leaf is string, boolean, character, byte, short, or integer. Generation is recursive: maps become `Record`, sequence values append `[]`, and nested maps become nested `Record` types. Floating-point leaves are rejected because non-finite JVM and JavaScript values do not share one JSON representation. The map property’s runtime constructor, `PropertyDescriptor`, and `@field` decorator use `Object`. A map property itself may be nullable; generated command accessors and model fields then include `undefined`. Jackson writes the map as a normal JSON object and OpenAPI emits `type: object` with recursive `additionalProperties` and the same property-name restrictions. The keys `__proto__`, `prototype`, and `constructor` are reserved: Arc rejects them during Jackson map serialization/deserialization, and generated command setters reject reserved own keys and objects with unsafe prototypes before transport. Null-prototype records with ordinary keys are supported. The bounded contract deliberately does not enable `ValueMap`, non-string keys, nullable entries or sequence elements, floating-point leaves, typed model/concept/enum/UUID/temporal leaves, map query parameters, or top-level query/command response maps. No `_entries` or entry-array wire shape is emitted. Use a generated model containing map properties when a command response or query result needs map data. ```typescript const command = new EchoMaps(); command.strings = { language: "typescript" }; command.numbers = { values: [1, 2] }; command.nested = { flags: { ready: true } }; ``` ## Preserve validation metadata KSP translates exactly representable Jakarta constraints on command properties and query parameters into Arc validation rules. Arc’s Jakarta-compatible `@Phone` and `@Url` annotations emit the TypeScript `phone` and `url` rules and preserve an explicitly supplied message. `@CreditCard` is enforced on the server and retained in manifest metadata, but is not emitted into TypeScript because the pinned `@cratis/arc` 22.10.4 has no `creditCard` rule-builder or runtime rule; generating the .NET extractor’s current output would produce an uncompilable proxy. Their default server messages are `must be a valid phone number`, `must be a valid URL`, and `must be a valid credit card number`. Optional Hibernate Validator `@URL` maps to the client `url` rule when Hibernate Validator is on the application’s compile classpath; `@CreditCardNumber` remains server-only for the same client-runtime limitation. Arc does not require Hibernate Validator at runtime. The manifest carries those rules and recursive `@Valid` metadata, and the generator emits the matching TypeScript validators. Rules declared on a concept value are merged with rules declared on the owning command property or query parameter, so the browser enforces the same combined constraints as the server even though the wrapper is erased. Unsupported JavaScript regular expressions, custom groups/payloads, contradictory bounds, and other unrepresentable client constraints stop generation with `ARCKSP0301` rather than silently weakening client validation. ## Propagate source documentation Kotlin KDoc and Java Javadoc reach the generated clients as single-line JSDoc comments. Documentation is read at compile time, carried in the artifact manifest, and rendered by the generator; nothing is read at runtime. * Java ```java /** * Creates an order for a customer. * * @param customerId Customer placing the order. */ @Command public record CreateOrder( /** Stable order identifier. */ @CommandKey String orderId, String customerId ) { } ``` * Kotlin ```kotlin /** * Creates an order for a customer. * * @property customerId Customer placing the order. */ @Command public data class CreateOrder( /** Stable order identifier. */ @CommandKey public val orderId: String, public val customerId: String ) ``` ```typescript /** Creates an order for a customer. */ export interface ICreateOrder { /** Stable order identifier. */ orderId?: string; /** Customer placing the order. */ customerId?: string; } ``` Summaries are captured for commands, query methods, client query parameters, model types and their serializable properties, interfaces and their properties, and enum declarations. A member without its own comment falls back to a Kotlin `@property` tag on the declaring class or a Java `@param` tag on the declaring class or record; a Kotlin `@param` tag documents a constructor parameter and is deliberately not projected onto a property. Only the first paragraph becomes a summary, folded onto one line and limited to 512 Unicode code points. A leading fenced, indented, or tab code block is skipped, control characters are dropped, and CR/LF plus the Unicode line controls `U+0085`, `U+2028`, and `U+2029` all end a line. The generator neutralizes a comment terminator and an at sign in tag position so a comment cannot end early or be misread as a JSDoc tag. Documentation is emitted on the command interface and class, the command interface property and its public getter, the query parameter interface and query class, client parameter fields inside the parameter interface, the model class and its generated fields before the decorators, the interface and its fields, and the enum declaration. It is deliberately absent from setters, private backing fields, runtime query fields, enum members, and barrel files, and OpenAPI keeps its conventional descriptions. Service, request, context, and host-adapter query parameters never carry documentation, because they never reach a generated client. ## Verify generated compatibility Command, one-shot and observable query, model, interface, derived-type, enum, and flags proxies compile in strict mode against the actual `@cratis/arc`, `@cratis/arc.react`, and `@cratis/fundamentals` packages. The repository contract gate installs exactly from `package-lock.json`, generates twice to prove byte stability, and runs `tsc --noEmit` with `verbatimModuleSyntax` enabled. The `:ContractTests:typeScriptRuntimeTest` gate then builds the executable Kotlin Spring Boot sample, copies its real generated proxies, starts the application, and executes the published TypeScript clients against it. The harness has five wired unit tests and enforces 35 behavioral runtime tests: four ignore-validation, four shared-fluent, and 15 general tests in separate UTC Node processes, plus six calendar tests in a separate UTC process and the same six in a separate `America/Los_Angeles` process. It parses each TAP summary and requires the exact test/pass count with zero fail, cancelled, skipped, or todo results; a Spring process-spawn error fails cleanly. The behavioral contract covers command validation and typed responses, malformed envelopes, correlation IDs, GET and RFC QUERY, paging and sorting, identity, multiplexed and direct WebSocket observable queries, direct SSE, scalar calendar/UUID serialization, generated-model hydration, timezone stability, and the documented `TimeOnly` truncation. This is an E2E runtime gate rather than a renderer-only fixture. Recursive type shapes were already present in artifact manifest format 5, and source summaries were introduced in format 6. Format 7 added optional typed command `eventMetadata`; current format 8 adds mandatory property `ignoreValidation` booleans while preserving immutable Java-friendly `TypeShapeDescriptor` metadata, legacy JVM constructor descriptors, and compatibility getters. Rebuild producer/dependency manifests and consumers in lockstep; see the [format-8 migration](/arc/backend/kotlin/reference/validation/#manifest-format-8-migration). A command without event defaults omits `eventMetadata`; the reader accepts exactly the current format so a classpath cannot silently mix metadata generations. Generated format 8 query parameter nodes include an explicit canonical value `source`; canonical file discovery requires it while legacy programmatic `ParameterDescriptor` constructors still project to `CLIENT` or `SERVICE` and serialize canonically. Unversioned, legacy-only, contradictory, or unsupported-context manifests fail. The temporal/UUID generated TypeScript type changes remain source-breaking for client consumers as described above. The separate `ArcGradlePluginTest` test `raw jvm proxy bytes equal the prepared expected fixture` generates a shared JVM artifact fixture and compares all regular files against the repository-local .NET-derived fixture under `GradlePlugin/src/test/resources/differential/dotnet/`. It compares sorted relative paths (only path separators are normalized), then untouched JVM bytes with `assertArrayEquals`, including headers, whitespace, line endings, and terminal newlines. Expected preparation never reads actual output or calls the production hash helper. Failures report byte lengths and the first differing byte offset. Mutation tests use this same reader, expected preparer, and comparator, rejecting altered bytes and paths, including a changed body with an internally valid recomputed hash. This remains a drift gate for the normalized fixture, not an exact raw .NET-output comparison; capture-time fixture preparation is not yet reproducible tooling. Capture SDK and tool versions remain unverified. The .NET-derived `FixtureModel.labelsByCategory` capture already contains `@field(Object)` and `Record` and receives no dictionary-shape rewrite, although its file receives the formatting preparation below. It proves only this string-key/string-value Record fixture, not non-string keys, nullable entries, typed model values, `ValueMap`, or broader dictionary parity. Covered shapes include commands, one-shot and observable queries, models, derived types, enums, flags, validators, indexes, and that bounded Record fixture. `Contracts/Shape.ts` is a class, not proof of interface emission. The current .NET fixture contains no `Guid`, `DateOnly`, or `TimeOnly`, so the temporal/UUID mapping itself is covered by focused generator and contract tests rather than this differential. Overall Arc .NET parity remains Partial. ### Verify the separate captured baseline offline The seven-file `differential/captured` comparison is separate from the nineteen-file historical fixture above. `:GradlePlugin:verifyCapturedProxyBaseline` runs before `:GradlePlugin:test` and binds that seven-file snapshot to the reviewed capture-input hashes and SDK/runtime/package/tool pins. Mutation tests prove that stale scripts/lockfiles, changed proxy bytes, renewed snapshot checksums, and malformed or incomplete metadata fail. Normal verification reads local repository files only; it requires Python but no .NET SDK, network, package cache, or retained session capture. The baseline receipt is a consistency check, not cryptographic proof that a publisher’s tool ran. After changing a generation input, use the capture harness’s explicit pinned-package capture and `verify_baseline.py --candidate ` workflow, review the normalized diff, then update the receipt and fixture together where necessary. The candidate command verifies raw-to-normalized bytes and current input pins, and prints JSON without changing either reviewed fixture. See the harness README under `GradlePlugin/src/test/resources/differential/capture/` for the exact commands and safety boundaries. Do not update hashes alone to clear stale-input failures. Overall parity remains Partial. ### Expected-only differential preparation Historical capture-time namespace and query-name casing transformations and removal of timestamps/hashes are already embedded in the 19 checked-in files; this test does not reproduce that capture. Its executable preparation is limited to: 1. Decode expected UTF-8, convert CRLF to LF, remove trailing whitespace on each expected line, and finish with one terminal LF. 2. In `Models/FixtureModel.ts`, replace double quotes with single quotes and three-space indentation with four spaces; preserve the Record declaration. 3. In `Commands/CreateFixtures.ts`, replace double quotes with single quotes, flatten the multiline React command import, change `this.ruleFor((c) =>` to `this.ruleFor(c =>`, expand the empty request-parameter array layout, and flatten the class declaration and static hook declaration/call. 4. At exactly one known `CreateFixtures` class declaration, correct `Command` to `Command`. The captured command already calls `super(FixtureModel, true)`; its scalar generic contradicts enumerable runtime behavior. 5. At exactly the known `CreateFixtures` static hook return, insert the `eslint-disable-next-line @typescript-eslint/ban-ts-comment` and `@ts-ignore` pair. Only the complete pair immediately before the exact return is accepted if already present; missing/duplicate hooks or misplaced/incomplete/additional suppressions fail preparation. This is not a type-soundness claim for ignored hook calls. 6. Apply five literal type-only import rewrites for `verbatimModuleSyntax`: React command `SetCommandValues`/`ClearCommandValues`; pageable one-shot `PerformQuery`/`SetSorting`/`SetPage`/`SetPageSize`; non-pageable one-shot `PerformQuery`/`SetSorting`; observable `ChangeSet`; and observable React `SetSorting`/`SetPage`/`SetPageSize`. Some are no-ops on the current capture; the one-shot rewrites remain active. 7. Only in `Models/Observe.ts`, require exactly one literal `ObserveParameters` interface block with a zero-space blank line immediately before its four-space-indented `filter: string;` member. Replace that blank line with exactly four spaces. This is a parameter-bearing interface, not an empty interface; `ObserveOne.ts` has no parameter interface and is not included. Missing/duplicate anchors fail preparation, other paths/blocks stay untouched, and wrong actual whitespace still fails the comparison. 8. For exactly `Models/All.ts`, `Models/Search.ts`, and `Models/Observe.ts`, replace the two parameter-derived sort-helper classes with return-field helpers for the literal list `identifier`, `labelsByCategory`, `permissions`, `state`, `updatedAt`. Their constructor no longer stores a public `query` field. `ExpectedSortHelperPreparation` checks unique class/end anchors and a fixed SHA-256 of each original block before changing it; the literal field list is cross-checked against fixture model metadata, never actual output. Only `All.ts` gains the previously absent `SortingActions`/`SortingActionsForQuery` imports. Request parameters, routes, hooks, and capability flags are untouched. Missing/duplicate/changed source blocks fail, and an actual helper pointing to `filter` instead of `identifier` fails the byte comparison. This is an explicit semantic correction for the JVM result-field behavior, not source-output parity. 9. Validate each captured source-only first line against the independent source table below, cross-checked against the JVM fixture descriptors. Remove only that validated expected header, SHA-256 hash the prepared expected body as UTF-8 (including the DO NOT EDIT banner and terminal LF), and prepend `// @generated by Cratis. Source: . Hash: <64 uppercase hex>` plus LF. The independent hash test asserts the fixed `abc` vector `BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD`. Unknown paths, wrong sources, and missing/duplicate markers fail preparation. No normalization transforms JVM output. The three indexes `Commands/index.ts`, `Contracts/index.ts`, and `Models/index.ts` stay headerless. The 16 artifact identities are literal expected data, not values inferred from actual headers. Queries use their declaring model, not their query fully qualified name. | Fixture path | Expected source identity | | --- | --- | | `Commands/CreateFixtures.ts` | `differential.fixture.commands.CreateFixtures` | | `Contracts/Shape.ts` | `differential.fixture.contracts.Shape` | | `Models/All.ts` | `differential.fixture.models.FixtureModel` | | `Models/ById.ts` | `differential.fixture.models.FixtureModel` | | `Models/Circle.ts` | `differential.fixture.models.Circle` | | `Models/EmptyModel.ts` | `differential.fixture.models.EmptyModel` | | `Models/FixtureModel.ts` | `differential.fixture.models.FixtureModel` | | `Models/FixturePermissions.ts` | `differential.fixture.models.FixturePermissions` | | `Models/FixtureState.ts` | `differential.fixture.models.FixtureState` | | `Models/GetEmpty.ts` | `differential.fixture.models.EmptyModel` | | `Models/GetShapes.ts` | `differential.fixture.models.ShapeHolder` | | `Models/Observe.ts` | `differential.fixture.models.FixtureModel` | | `Models/ObserveOne.ts` | `differential.fixture.models.FixtureModel` | | `Models/Search.ts` | `differential.fixture.models.FixtureModel` | | `Models/ShapeBase.ts` | `differential.fixture.models.ShapeBase` | | `Models/ShapeHolder.ts` | `differential.fixture.models.ShapeHolder` | Published Kotlin APIs are also guarded by checked-in binary-compatibility baselines. `apiCheck` covers the runtime integrations and testing module as well as `arc-ksp` and `arc-gradle-plugin`, so compiler and build-tool contracts cannot drift without an intentional baseline update. # Share fluent validation with generated clients > Declare Kotlin and Java input rules, reject invalid commands before handling, and display the same feedback in generated TypeScript clients. ## Set up shared validation Use `FluentModelValidator` when a literal member rule must run on the server and in generated TypeScript. Keep service-dependent checks, asynchronous work, arbitrary predicates, cross-member conditions and warning/information feedback in ordinary `ModelValidator`, `CommandValidator` or `QueryValidator` implementations. Those remain server-only. This is a bounded authoring surface over Arc’s existing pipeline, not full FluentValidation compatibility. Start with the [Kotlin](/arc/backend/kotlin/get-started/#configure-gradle) or [Java](/arc/backend/kotlin/get-started/java/#configure-gradle) application setup: JDK 17, Gradle 8.14.4, Kotlin 2.4.20, KSP 2.3.12 and Spring Boot 4.1.x. The `io.cratis.arc` plugin supplies `io.cratis:arc`, the `arc-ksp` processor and the verified dependency index required by shared validation. Add `io.cratis:arc-spring-boot-starter:` and `spring-boot-starter-webmvc` to the application. Java applications still need Kotlin/KSP because Arc generates Kotlin adapters for Java declarations. The local workspace version is `0.0.0-SNAPSHOT`; use a released version for published dependencies. Set an explicit module identity and proxy output directory. The following configuration is an adaptation of the plugin setup exercised by `ArcFluentValidationNativeFunctionalTest`, using the package and identity of the runnable sample below; this exact combined build snippet is not a separately compiled tutorial: ```kotlin cratisArc { moduleName.set("KotlinSpringBootSample") dependencyVersion.set("") endpoints { segmentsToSkip.set(6) enableQueryHttpMethod.set(true) } proxies { outputDirectory.set(layout.buildDirectory.dir("generated/arc-proxies")) segmentsToSkip.set(6) } } ``` An unset proxy output directory skips generation. Match route settings in `src/main/resources/application.properties`: ```properties cratis.arc.endpoints.segments-to-skip-for-route=6 cratis.arc.endpoints.enable-query-http-method=true ``` For the client, the repository contract pins `@cratis/arc` and `@cratis/arc.react` to **22.10.4**, `@cratis/fundamentals` to **7.19.3**, React to **19.2.8**, and TypeScript to **7.0.2**, running on Node 22. See [proxy setup](/arc/backend/kotlin/guides/typescript-proxies/) for compiler settings; use the pinned packages rather than assuming another runtime has the same rules. Jakarta validation is independent: add a Jakarta provider, such as `spring-boot-starter-validation`, if you also use Jakarta constraints. Shared fluent rules themselves do not require Jakarta. ## Author the input and command The primary walkthrough uses the actual runnable `Samples/Kotlin/SpringBoot` application, not unpublished `ContractTests` fixture imports. Its `runtime.fluent.contract.ts` exercises these sources against a real Spring host. Work in that sample to run the unchanged example: ```bash ./gradlew :Samples:Kotlin:SpringBoot:bootRun ``` The sample has its own manual build wiring; do not replace it with the plugin adaptation above. Its module is `KotlinSpringBootSample`, producing `io.cratis.arc.generated.KotlinSpringBootSampleArcArtifactModule` and `META-INF/cratis/arc/KotlinSpringBootSample.json`. Spring discovers the generated module through `ServiceLoader`; do **not** add a second validator bean. See the [registration and library contract](/arc/backend/kotlin/reference/validation/#registration-and-library-packaging) when splitting the application into modules. ### Declare Kotlin rules The following files are under `Samples/Kotlin/SpringBoot/src/main/kotlin/io/cratis/arc/samples/kotlin/springboot/`. Each nullable input slot isolates a validation rule from JSON binding requirements. `FluentInput.kt`: ```kotlin package io.cratis.arc.samples.kotlin.springboot data class FluentInput( val required: String?, val nonempty: String?, val minimum: String?, val maximum: String?, val range: String?, val email: String?, val phone: String?, val url: String?, val pattern: String?, val greater: Double?, val atLeast: Double?, val less: Double?, val atMost: Double? ) ``` `FluentInputRules.kt`: ```kotlin package io.cratis.arc.samples.kotlin.springboot import io.cratis.arc.validation.FluentModelValidator class FluentInputRules : FluentModelValidator(FluentInput::class.java) { init { ruleFor("required").notNull() ruleFor("nonempty").notEmpty().withMessage("{PropertyName} must have text; {PropertyName}") ruleFor("minimum").minLength(2) ruleFor("maximum").maxLength(2) ruleFor("range").length(1, 3) ruleFor("email").emailAddress() ruleFor("phone").phone() ruleFor("url").url() ruleFor("pattern").matches("^([A-Z]+|[\\]]+)$") ruleFor("greater").greaterThan(2) ruleFor("atLeast").greaterThanOrEqual(2) ruleFor("less").lessThan(2) ruleFor("atMost").lessThanOrEqual(2) } } ``` Use a public final top-level validator, one concrete model class and one public no-argument constructor. Kotlin’s default public/final modifiers suffice. The single `init` block may contain only direct chains with literal arguments. Select one public readable member, not a dotted path or index. KSP checks the complete restricted body without executing application constructors. See the [declaration limits](/arc/backend/kotlin/reference/validation/#declaration-and-member-contract) before adding helpers, computed accessors or additional members. ### Add ordinary Java rules These two files coexist with the Kotlin sources; they are not alternative definitions of the same types. Put them under `Samples/Kotlin/SpringBoot/src/main/java/io/cratis/arc/samples/kotlin/springboot/`. `JavaFluentInput.java`: ```java package io.cratis.arc.samples.kotlin.springboot; public record JavaFluentInput(String name) { } ``` `JavaFluentInputRules.java`: ```java package io.cratis.arc.samples.kotlin.springboot; import io.cratis.arc.validation.FluentModelValidator; public final class JavaFluentInputRules extends FluentModelValidator { public JavaFluentInputRules() { super(JavaFluentInput.class); ruleFor("name").notEmpty(); } } ``` Java uses an ordinary constructor and method calls, with no Kotlin lambda or `Continuation`. Records with default or source-proved identity accessors and public fields are supported. ### Connect the model to a command Back in the Kotlin source directory, `FluentGroup.kt` shows a shared child without its own validator or Jakarta `@Valid` marker: ```kotlin package io.cratis.arc.samples.kotlin.springboot data class FluentGroup(val child: FluentInput) ``` `ValidateFluent.kt` supplies the command handler: ```kotlin package io.cratis.arc.samples.kotlin.springboot import io.cratis.arc.artifacts.Command import io.cratis.arc.authorization.AllowAnonymous @Command @AllowAnonymous data class ValidateFluent( val input: FluentInput?, val siblings: List, val javaInput: JavaFluentInput?, val group: FluentGroup? = null ) { fun handle(): String = "accepted" } ``` Shared rules are applied to present nested nodes automatically, including collection siblings. A null `input` creates no model node: its child’s `required.notNull()` does not require the parent object itself. Put a presence rule on the parent member when the parent is required. ## Opt out one member Use `io.cratis.arc.validation.IgnoreValidation` when a serialized member must not be read for member/descendant validation. Kotlin property, field and getter placements select the same logical edge; Java uses a field, bean getter or record component, and the Java tab shows the record-component form: * Java ```java public record Input(@IgnoreValidation String ignored, String sibling) { } ``` * Kotlin ```kotlin data class Input( @IgnoreValidation val property: String?, @field:IgnoreValidation val field: String?, @get:IgnoreValidation val getter: String?, val sibling: String? ) ``` These are placement illustrations, not additional runnable sample artifacts. Keep any authored fluent declarations intact: their compiler/runtime fingerprints still include the ignored member, while effective evaluation omits it. Nonignored siblings and separately validated child roots still run. This is not `@JsonIgnore`, does not waive required binding, and does not remove owner-level imperative/Jakarta class validation or executable query-parameter constraints. Read the [full opt-out, custom-validator and format-8 contract](/arc/backend/kotlin/reference/validation/#ignore-a-validation-member-edge) before using custom Jakarta validators or distributing compiled models. In particular, Arc composes the application’s actual factory resolver before access, never filters already-read violations, and fails closed when an opaque validator cannot guarantee the cut. Runtime bean-only Java getters are a new traversal surface; this does not broaden the compiler’s shared-model restrictions. ### Run the Kotlin and Java opt-out samples The same runnable Kotlin Spring Boot sample contains `IgnoredInput.kt`, `IgnoredInputRules.kt`, `ValidateIgnored.kt` and `IgnoredView.kt`. Its ordinary Java sources are `JavaIgnoredInput.java`, `JavaIgnoredInputRules.java`, `ValidateJavaIgnored.java` and `JavaIgnoredView.java` in the parallel `src/main/java` package. No manual validator beans are needed. Their generated model, command and QUERY validators are exercised by `runtime.ignore-validation.contract.ts`. With that sample running, the following request is accepted despite invalid ignored text/children and an empty array that contradicts its still-declared fluent minimum: ```bash input='{"ignoredText":"","ignoredChild":{"name":""},"ignoredList":[{"name":""}],"ignoredArray":[],"ignoredMap":{"wire":""},"validated":{"name":"ok"},"sibling":"ok"}' curl -sS -X POST http://localhost:8080/api/validate-ignored \ -H 'Content-Type: application/json' -d "{\"input\":$input}" curl -sS -X QUERY http://localhost:8080/api/ignored \ -H 'Content-Type: application/json' -d "{\"arguments\":{\"input\":$input}}" ``` The QUERY response’s `data.value` is the empty ignored text: the member still binds and the handler can read it. Change `validated.name` or `sibling` to empty to get active-member rejection. The Java routes are `/api/validate-java-ignored` and `/api/java-ignored`; omit `ignoredArray` and `ignoredMap` from that Java record’s body. Append `/validate` to either command route for preflight. These shell commands adapt the executable contract’s `send` calls. That contract also uses actual `ValidateIgnored.execute()`, `ValidateJavaIgnored.execute()`, `CheckIgnored.perform()` and `CheckJavaIgnored.perform()`, including local rejection before fetch. Its getter/container/cycle probes call validators directly: they do not claim arbitrary JavaScript getters, aliases or cycles survive JSON serialization. The ignored self edge is omitted in generated command transport examples; the pinned serializer fails on explicit null nested values, including this sample’s `ignoredNext: null`. Raw JSON requests separately prove that explicit null binds on the server. `@IgnoreValidation` does not change that serializer limitation. The sample intentionally has no Jakarta provider. Real-provider POST/preflight/QUERY proof is in `Integrations/SpringBoot/ArcIgnoreValidationHostingTests`, using Kotlin and ordinary Java inputs, throwing getter counters, live owner class/group-sequence callbacks, and active sibling constraints. `IgnoreValidationProviderJavaTest` separately proves pre-extractor suppression with the actual provider. The generated Kotlin/Java in-process scenario, fingerprint, JSON and OpenAPI agreement checks are `ContractTests/IgnoreValidationContractTest` and `IgnoreValidationJavaContractTest`. ## Execute and inspect rejection First distinguish the three operations: | Operation | What runs | Does the handler run? | | --- | --- | --- | | `JavaFluentInputRules().validate(model)` | This declaration’s direct member rules only; relative paths such as `name` | No pipeline or handler | | `CommandPipeline.validate(command, options)` | Configured command filters, including authorization and validation; nested paths such as `javaInput.name` | No handler, execution scopes or response handling | | `CommandPipeline.execute(command, options)` | Normal execution, including validation | Only if validation and the other gates permit it | A successful preflight is not permission to skip validation during execution; the input or server-side policy may have changed. The in-process APIs do not replace HTTP binding. With the sample running, send the all-valid command used by `runtime.fluent.contract.ts` (the shell request is an adaptation of its `post` helper): ```bash curl -sS -X POST http://localhost:8080/api/validate-fluent \ -H 'Content-Type: application/json' \ -d '{"input":{"required":"ok","nonempty":"ok","minimum":"ab","maximum":"ab","range":"ab","email":"a@b.c","phone":"+47 123","url":"HTTPS://a","pattern":"ABC","greater":3,"atLeast":2,"less":1,"atMost":2},"siblings":[],"javaInput":{"name":"ok"}}' ``` Expect `isSuccess: true`, an empty `validationResults` array and the handler’s `"accepted"` response. Repeat with `"pattern":"bad"` in place of `"pattern":"ABC"`: expect `isSuccess: false` and `validationResults` containing message `'pattern' is not in the correct format.` with `members: ["input.pattern"]`. This is server enforcement even though curl bypasses the client. ### Invoke from Kotlin or Java These two usage adaptations combine the sample command with the published pipeline APIs exercised by `BlockingPipelineJavaConformanceTest` and `ScenarioFluentValidationTests`. They are not newly compiled sample files. Both deliberately use the sample’s `@AllowAnonymous` endpoint, no tenant and a fresh correlation ID. In a real application, capture the intended identity, tenant and `ServiceResolver` at entry; do not infer them from thread locals or untrusted input. For Kotlin, place `FluentOperations.kt` in the same sample package. Supply the Spring-injected `CommandPipeline` and `ServiceResolver`, and call from a coroutine: ```kotlin package io.cratis.arc.samples.kotlin.springboot import io.cratis.arc.authorization.ArcPrincipal import io.cratis.arc.commands.CommandExecutionOptions import io.cratis.arc.commands.CommandPipeline import io.cratis.arc.commands.ServiceResolver import java.util.UUID suspend fun checkFluent(commands: CommandPipeline, services: ServiceResolver) { val options = CommandExecutionOptions( UUID.randomUUID(), ArcPrincipal.anonymous(), services, null, null, null, false ) val rules = JavaFluentInputRules() check(rules.validate(JavaFluentInput("ok")).isEmpty()) check(rules.validate(JavaFluentInput("")).single().members == listOf("name")) val accepted = ValidateFluent(null, emptyList(), JavaFluentInput("ok"), null) check(commands.validate(accepted, options).isSuccess) // No handler invocation. check(commands.execute(accepted, options).response == "accepted") val rejected = commands.execute(accepted.copy(javaInput = JavaFluentInput("")), options) check(!rejected.isSuccess) check(rejected.validationResults.single().members == listOf("javaInput.name")) check(rejected.validationResults.single().message == "'name' must not be empty.") } ``` For ordinary Java, put `FluentOperations.java` in that package. This is an imperative caller, not an Arc handler or coroutine: supply Spring’s `BlockingCommandPipeline` bean and service resolver. All four Kotlin command constructor arguments and all seven options arguments are explicit. ```java package io.cratis.arc.samples.kotlin.springboot; import io.cratis.arc.authorization.ArcPrincipal; import io.cratis.arc.commands.CommandExecutionOptions; import io.cratis.arc.commands.ServiceResolver; import io.cratis.arc.java.BlockingCommandPipeline; import java.util.List; import java.util.UUID; public final class FluentOperations { public static void check(BlockingCommandPipeline commands, ServiceResolver services) { var options = new CommandExecutionOptions( UUID.randomUUID(), ArcPrincipal.anonymous(), services, null, null, null, false); var rules = new JavaFluentInputRules(); if (!rules.validate(new JavaFluentInput("ok")).isEmpty()) throw new AssertionError(); if (!rules.validate(new JavaFluentInput("")).get(0).getMembers().equals(List.of("name"))) { throw new AssertionError(); } var accepted = new ValidateFluent(null, List.of(), new JavaFluentInput("ok"), null); if (!commands.validate(accepted, options).isSuccess()) throw new AssertionError(); if (!"accepted".equals(commands.execute(accepted, options).getResponse())) throw new AssertionError(); var rejected = commands.execute( new ValidateFluent(null, List.of(), new JavaFluentInput(""), null), options); if (rejected.isSuccess()) throw new AssertionError(); var feedback = rejected.getValidationResults().get(0); if (!feedback.getMembers().equals(List.of("javaInput.name"))) throw new AssertionError(); if (!feedback.getMessage().equals("'name' must not be empty.")) throw new AssertionError(); } } ``` The blocking facade occupies the caller thread. See its [context and interruption limits](/arc/backend/kotlin/get-started/java/#blocking-interruption-and-limits). For tests without a host, add `testImplementation("io.cratis:arc-testing:")` and use `CommandScenario(KotlinSpringBootSampleArcArtifactModule(), ValidateFluent::class.java)` or Java’s closeable `BlockingCommandScenario`; see [in-process testing](/arc/backend/kotlin/guides/testing/). Do not import `io.cratis.arc.contracts.fixtures`: `ContractTests` is unpublished. The actual runnable HTTP contract accepts the all-valid input shown in the client section below. Changing only `pattern` to `"bad"` rejects with message `'pattern' is not in the correct format.` and member `input.pattern`. Empty Java `name` rejects with `'name' must not be empty.` and `javaInput.name`. Shared feedback has error severity. `ScenarioFluentValidationTests` explicitly counts zero handler invocations on rejection and proves that even successful `validate` does not invoke the handler; that repository test uses a manual module illustration, not the sample handler. The sample’s real HTTP acceptance/rejection is covered separately by `runtime.fluent.contract.ts`. ## Display generated client feedback Generate, rather than hand-maintain, the validators: ```bash ./gradlew :Samples:Kotlin:SpringBoot:generateArcProxies ``` The plugin-based application uses `./gradlew generateArcProxies`. The sample generates into `Samples/Kotlin/SpringBoot/build/generated/arc-proxies`; the contract copies that output to `ContractTests/TypeScript/generated/runtime`. **Generated excerpt**, from `FluentInput.ts` inside the generated validator constructor: ```typescript this.arcRules0.ruleFor(c => c.minimum).minLength(2); this.arcRules0.ruleFor(c => c.nonempty).notEmpty().withMessage('{PropertyName} must have text; {PropertyName}'); this.arcRules0.ruleFor(c => c.pattern).matches(/^([A-Z]+|[\]]+)$/); ``` `ValidateFluent.ts` exports `ValidateFluentValidator` and a `ValidateFluent` command whose `validation` property uses it. That validator composes `FluentInputValidator`, `JavaFluentInputValidator` and `FluentGroupValidator`, qualifying nested member paths. **Handwritten caller**, adapted from `runtime.fluent.contract.ts`. These import paths assume a file under `ContractTests/TypeScript/contracts`; change only the generated-directory prefix in your app. Use your host origin if it is not the local sample’s port 8080: ```typescript import { Globals } from '@cratis/arc'; import type { FluentInput } from '../generated/runtime/FluentInput'; import { ValidateFluent } from '../generated/runtime/ValidateFluent'; Globals.origin = 'http://localhost:8080'; Globals.apiBasePath = ''; const input: FluentInput = { required: 'ok', nonempty: 'ok', minimum: 'ab', maximum: 'ab', range: 'ab', email: 'a@b.c', phone: '+47 123', url: 'HTTPS://a', pattern: 'ABC', greater: 3, atLeast: 2, less: 1, atMost: 2 }; const command = new ValidateFluent(); command.input = input; command.siblings = []; command.javaInput = { name: 'ok' }; const accepted = await command.execute(); // isSuccess === true command.input = { ...input, pattern: 'bad' }; const rejected = await command.execute(); // isSuccess === false; no HTTP request for (const feedback of rejected.validationResults) { console.log(feedback.members, feedback.message); // ['input.pattern'], "'pattern' is not in the correct format." } ``` The contract explicitly asserts zero transport calls for that rejected `execute()`. The server separately rejects invalid requests that bypass the client. Browser feedback never authorizes a request or replaces binding, authentication, authorization, Jakarta or server-only validation. For a nested example, an invalid `group.child.maximum`, `input.minimum`, `javaInput.name` and second sibling’s `pattern` produce paths `group.child.maximum`, `input.minimum`, `javaInput.name` and `siblings[1].pattern` in the runnable contract. ## Validate a supplied query model Use request-response RFC QUERY and explicit `@QueryHttpMethod(QueryHttpMethodType.QUERY)` preference for shared model arguments. Enable QUERY in both host and proxy configuration, as in setup. Shared-model GET and observable proxy transport reject rather than dropping validation. Here is the complete existing `FluentView.kt` in the same Kotlin sample package. The argument model and its ordinary Java validator are the files already shown above: ```kotlin package io.cratis.arc.samples.kotlin.springboot import io.cratis.arc.artifacts.ReadModel import io.cratis.arc.authorization.AllowAnonymous import io.cratis.arc.queries.Path import io.cratis.arc.queries.QueryHttpMethod import io.cratis.arc.queries.QueryHttpMethodType import io.cratis.arc.queries.QueryRequest @ReadModel @AllowAnonymous data class FluentView(val value: String) { companion object { @Path("/api/fluent") @QueryHttpMethod(QueryHttpMethodType.QUERY) fun checkFluent(input: JavaFluentInput? = JavaFluentInput("default")): FluentView = FluentView(input?.name ?: "null") @Path("/api/fluent-batch") @QueryHttpMethod(QueryHttpMethodType.QUERY) fun checkFluentBatch(inputs: Array? = null, request: QueryRequest): FluentView = FluentView(inputs?.size?.toString() ?: if (request.arguments.containsKey("inputs")) "null" else "omitted") } } ``` Send a complete QUERY envelope, not a bare model: ```bash curl -sS -X QUERY http://localhost:8080/api/fluent \ -H 'Content-Type: application/json' \ -d '{"arguments":{"input":{"name":"ok"}}}' ``` Replace the body to exercise the cases asserted by `runtime.fluent.contract.ts`: | JSON body | Result | | --- | --- | | `{"arguments":{}}` | Success; `data.value` is `"default"` | | `{"arguments":{"input":null}}` | Success; `data.value` is `"null"` | | `{"arguments":{"input":{"name":"ok"}}}` | Success; `data.value` is `"ok"` | | `{"arguments":{"input":{"name":""}}}` | Failure before invocation; member `input.name`, message `'name' must not be empty.` | Omission stays absent in `QueryRequest.arguments` and executes the Kotlin default only at the model boundary. Explicit null retains its key and creates no node; it is permitted because the argument is nullable. A supplied object binds to the concrete model and is validated before invocation. Returned query data is not input validation. Scalar arguments still reject object/array payloads. For `/api/fluent-batch`, bodies with `arguments` equal to `{}`, `{"inputs":null}`, `{"inputs":[]}` and `{"inputs":[{"name":" a "}]}` return `"omitted"`, `"null"`, `"0"` and `"1"` respectively. Two supplied empty names reject at `inputs[0].name` and `inputs[1].name`. Generated `CheckFluentBatchParameters` uses `inputs?: JavaFluentInput[] | null`: null applies to the container, not its entries. The contract also calls `new CheckFluentBatch().perform(arguments)` for these accepted shapes. The Java model and validator participate here, but the query method is Kotlin. Java query methods are static and have no Arc default-argument feature. This walkthrough does not claim an executed ordinary-Java shared-query invocation; see [Java queries](/arc/backend/kotlin/guides/queries/) for the available surface. ## Migrate deliberately | Existing approach | Shared alternative | Keep in mind | | --- | --- | --- | | Imperative `ModelValidator`, `CommandValidator` or `QueryValidator` | Move literal representable member rules to `FluentModelValidator` | Keep service-dependent, asynchronous and cross-member policy server-only; remove old copies only if duplicate feedback is unwanted | | Manually registered validator bean | Let the generated module contribute it | An equivalent fluent bean is deduplicated by exact declaration class; a missing compiler contribution or rule mismatch fails registration | | Jakarta annotations | Retain them, optionally adding fluent rules | Annotation and DSL metadata form a union — see [conjunction, duplication and contradictions](/arc/backend/kotlin/reference/validation/#conjunction-duplication-and-contradictions); Jakarta still runs separately on the server, so this is not cross-engine feedback deduplication | | Implicit required values | Choose `notNull` or `notEmpty` explicitly | Other shared rules pass null; format rules also pass empty strings; a null parent creates no child node | | Jakarta/localized or imperative messages | Use literal `withMessage` for a shared message | It changes only the preceding rule and replaces only the first `{PropertyName}`; the sample emits `nonempty must have text; {PropertyName}` | | Server-only constraints with no client equivalent | Retain server enforcement | New shared `creditCard()` rejects; existing Jakarta credit-card metadata remains server-only | | Handwritten client checks | Regenerate model, command and supported query validators | Generated feedback is early assistance, never a server-validation bypass | Continue with the [exact rules, numeric failures and tooling reference](/arc/backend/kotlin/reference/validation/). No shared OpenAPI projection is claimed by this walkthrough; existing annotation/default behavior is documented separately in the [OpenAPI guide](/arc/backend/kotlin/guides/openapi/). These are JVM-specific shared-validation contracts, not a claim of full Arc .NET parity. # Arc reference > Look up Arc annotations, configuration, HTTP contracts, and current JVM feature status. ## Reference sections * [Annotations](/arc/backend/kotlin/reference/annotations/) * [Configuration](/arc/backend/kotlin/reference/configuration/) * [HTTP contract](/arc/backend/kotlin/reference/http-contract/) * [Shared fluent validation](/arc/backend/kotlin/reference/validation/) * [TypeScript proxy options matrix](/arc/backend/kotlin/reference/typescript-proxy-options/) * [Glossary](/arc/backend/kotlin/reference/glossary/) * [Feature parity](/arc/backend/kotlin/reference/parity/) # Annotation reference > Exact Arc annotations available to Kotlin and Java application models. ## Artifact annotations | Annotation | Target | Contract | | --- | --- | --- | | `@Command` | Class | Marks a model-bound command with an instance `handle` method. | | `@ReadModel` | Class | Marks a model whose static or companion methods are queries. | | `@ExportedType` | Class | Makes a type an artifact root when no command or query references it. See [Exported types](#exported-types). | | `@CommandKey` | Property, field, value parameter, getter | Selects the command identity. Required for plain Chronicle event responses. | | `@FromServices` | Value parameter | Resolves a query parameter from the host service container instead of the request. Command handler dependencies are service-resolved by their generated signature. | | `@TreatWarningsAsErrors` | Class, function | Makes information and warning validation feedback blocking by default. | Generated command, model, read-model, and interface sequence properties require nonnullable elements. KSP reports `ARCKSP0300` for nullable entries, including Java record `List<@Nullable T>` type uses; nullable outer `List?`, `Collection?`, and `Array?` remain supported. This is an earlier source rejection of an already unsupported manifest shape, not a source-compatible change. See [sequence properties](/arc/backend/kotlin/guides/typescript-proxies/#declare-nonnullable-sequence-elements). Public declared Kotlin body properties participate alongside constructor properties: body `@CommandKey`, field/getter constraints, `@Valid`, and summaries are retained. Multiple constructor/body keys fail with `ARCKSP0106`. Member `@JsonIgnore` excludes state; computed or explicitly read-only Kotlin command input and unrepresentable body Jackson names/access fail with `ARCKSP0300`. Separate output-only computed models remain supported. See [command body state](/arc/backend/kotlin/guides/commands/#declare-body-state-explicitly) for ordering, Jackson mutability, and inheritance boundaries. ### Exported types Code generation normally reaches model types through `@Command` and `@ReadModel`. A module that contributes only identity detail types or shared data-transfer types has no such root, so it emits no manifest and no generated client. `@ExportedType` makes the annotated type a root: it and every type reachable from it are collected, and the compilation emits an artifact module and manifest even with no commands and no queries. Apply it to a public top-level concrete class, enum class, interface, or concept. Unsupported targets — abstract classes, generic definitions, nonpublic types, and nested types — fail with `ARCKSP0306`. Abstract bases are reached through their concrete derived types instead. * Java ```java @ExportedType public record SampleIdentityDetails(String source) { } ``` * Kotlin ```kotlin @ExportedType public data class SampleIdentityDetails(public val source: String) ``` This is a deliberate divergence from Arc .NET, which discovers identity detail types by runtime reflection over the classpath and exports whole assemblies with a `--library-mode` flag. KSP runs at compile time, cannot see into function bodies, and cannot enumerate dependency types, so Arc.Kotlin requires the marker rather than inferring roots and silently missing the ones it cannot observe. ### Kotlin visibility KSP uses Kotlin visibility, not the presence of a `public` keyword. Implicit-public command/read-model classes, `handle`, `provide`, companion queries, constructor/body properties, and public interface overrides are supported. A public property with a private setter remains included; private, internal, and protected Kotlin state remains excluded. Generated framework declarations retain explicit `public` keywords. Artifacts must still be top-level public classes. Nonpublic command/read-model classes fail with `ARCKSP0101`/`ARCKSP0200`; nonpublic `handle`/`provide`/query methods fail with `ARCKSP0102`/`ARCKSP0103`/`ARCKSP0201`. An actual query in a private or internal companion also fails with `ARCKSP0201`; unrelated private companion helpers do not become queries. Java package visibility is not public, and existing Java record/property handling is unchanged. This does not add a constructor-visibility requirement or broaden supported nested model shapes. Previously omitted implicit-public properties now contribute fields, keys, validation, and reachable types to descriptors, manifests, and clients. Existing input-shape diagnostics also apply to them, including `ARCKSP0300` for computed command input. Implicit-public command-like methods and state now participate in the missing-`@Command` warning (`ARCKSP0100`), and implicit external handlers accepting an `@Command` are rejected with `ARCKSP0102`. Newly accepted `provide` methods retain the unused-value warning (`ARCKSP0107`); newly discovered duplicate keys fail with `ARCKSP0106`. Review regenerated clients and compiler diagnostics when upgrading; this is a source-acceptance correction with no ABI or manifest-format change. ## Validation annotations [Shared fluent validators](/arc/backend/kotlin/guides/validation/) are discovered from public final direct `FluentModelValidator` declarations, not a replacement annotation. They add validated explicit rules to the existing metadata graph and generated runtime registration. `ARCKSP0308` rejects unsupported constructor bodies, `ARCKSP0309` rejects unrepresentable rules, and `ARCKSP0310` rejects missing/conflicting compiler metadata. New shared `creditCard()` is rejected; the legacy annotation boundary below is unchanged. | Annotation | Target | Contract | | --- | --- | --- | | `@IgnoreValidation` | Kotlin property/field/getter; Java instance field/bean getter/record component | Cuts one logical validation edge before access while keeping serialization and binding. See [member opt-out and Jakarta factory integration](/arc/backend/kotlin/reference/validation/#ignore-a-validation-member-edge), including `ARCKSP0311` boundaries. | | `@Phone` | Field, property getter, value parameter, annotation class | Accepts empty values or strings containing only ASCII digits, JavaScript whitespace, parentheses, plus, and hyphen; use a presence constraint when empty is invalid. | | `@Url` | Field, property getter, value parameter, annotation class | Accepts empty values or strings beginning with `http://` or `https://` and a nonempty value after the scheme; use a presence constraint when empty is invalid. | | `@CreditCard` | Field, property getter, value parameter, annotation class | Accepts empty values or Luhn-valid ASCII card numbers containing optional spaces or hyphens; use a presence constraint when empty is invalid. | `@Phone`, `@Url`, and `@CreditCard` are Jakarta constraints, work from Kotlin and Java, and remain in generated validation metadata. `@Phone` and `@Url` also emit matching TypeScript runtime rules. `@CreditCard` is server-only because the pinned `@cratis/arc` client runtime has no compatible credit-card rule; emitting the current .NET extractor shape would make the proxy uncompilable. Hibernate Validator `@URL` follows the same client mapping as `@Url` when present at compile time, while `@CreditCardNumber` has the same server-only boundary as `@CreditCard`. `null` is valid, so nullability remains the responsibility of `@NotNull`. ## Routing and transport annotations | Annotation | Target | Values and behavior | | --- | --- | --- | | `@Path(value)` | Class, function | Overrides query path metadata. Explicit query paths are preserved verbatim. | | `@QueryHttpMethod(value)` | Read-model class, function | Proxy preference: `AUTO` (default), `GET`, or `QUERY`. A class value defaults every query; a method value overrides it. | | `@QueryTransport(value)` | Function | `REQUEST_RESPONSE` (default) or `OBSERVABLE`. KSP infers `OBSERVABLE` for Kotlin `Flow` and JDK `Flow.Publisher`; Spring hosts observable HTTP snapshots, direct SSE/WebSocket, and multiplexed hubs. | ## Command event metadata annotations | Annotation | Target | Values and behavior | | --- | --- | --- | | `@CommandEventSourceType(value)` | Command class | Default event-source type for events returned by the command. | | `@CommandEventStreamType(value)` | Command class | Default event-stream type for returned events. | | `@CommandEventStreamId(value)` | Command class | Default event-stream identifier; when absent Chronicle uses the event-source identifier. | | `@CommandEventSubject(value)` | Command class | Default compliance subject; when absent Chronicle uses the event-source identifier. | Each annotation is optional. A present value must be nonblank and contain no control characters; KSP reports `ARCKSP0110` otherwise. Generated `CommandEventMetadata` retains absence as `null`, so Chronicle remains responsible for its own `Default` and event-source-ID fallbacks rather than Arc serializing those literals. These are static command defaults. A command can implement `CommandEventStreamIdProvider` or `CommandEventSubjectProvider` when those values depend on its instance; generated code rejects a static annotation combined with the matching provider under `ARCKSP0110`, so one slot never has two sources. An explicit `EventForEventSourceId` value keeps its own stream/source/subject metadata, while a missing wrapper value inherits the composed command declaration. This deliberately preserves the JVM’s existing explicit-event precedence. ## Authorization annotations | Annotation | Target | Values and behavior | | --- | --- | --- | | `@AllowAnonymous` | Class, function | Allows unauthenticated access. | | `@Authorize` | Class, function | Optional `policy`, `roles`, and `schemes` arrays. Requires an authenticated caller. | | `@Roles(vararg value)` | Class, function | Repeatable declaration requiring at least one named role. | | `@RolesContainer(value)` | Class, function | JVM container generated for repeated `@Roles`; application code normally does not use it directly. | ### Class and operation precedence An operation — a command `handle` function or a read-model query function — that declares any `@Authorize` or `@Roles` replaces its class’s declaration completely. Its policy, roles, and schemes are the only ones evaluated, and the class’s are discarded. The class declaration applies only to operations that declare none. An operation therefore overrides the class rule rather than narrowing it, and it can admit callers the class rule would reject: on a class requiring `admin`, a `@Roles("auditor")` operation admits auditors and rejects admins. Repeating `@Roles` on the same target still combines those roles, and a caller satisfies a role list by holding any one of its roles. `@AllowAnonymous` cannot be combined with `@Authorize` or `@Roles` on the same declaration. KSP reports `ARCKSP0108` and stops generation rather than guessing which one was meant. Splitting them across a class and its operation is not a conflict: the operation’s metadata replaces the class’s, so a closed read model can expose one anonymous query - see [Security](/arc/backend/kotlin/guides/security/). `@AllowAnonymous` repeated on both the class and its operation is also `ARCKSP0108`, because it is redundant. ## Serialization annotations | Annotation | Target | Contract | | --- | --- | --- | | `@DerivedType(id)` | Class | Adds `_derivedTypeId` and registers a stable identifier for polymorphic Arc JSON. The identifier must be nonblank and unique for its base type. Code generation records the base-to-derivative mappings, and a host registers them so the identifier resolves when reading. See [Polymorphic reads](#polymorphic-reads). | | `@Flags` | Class | Marks an enum as a bit field. Generated TypeScript gains an `all` constant combining every nonzero member. Nothing about JVM serialization changes. | | `@ArcEnumValue(value)` | Field | Declares an enum member’s integer wire value where KSP cannot prove it from a single integer-literal constructor argument. | The annotation alone does not make a value readable again. Resolving an identifier back to a class is the job of `DerivedTypeRegistry`, and Arc never scans the classpath to fill it, so a base type’s concrete types must be registered before it is first deserialized. Serialization refuses a value whose base type is registered while the value’s own type is not, and names both types. Such a value would otherwise be written with an identifier nothing can resolve and could never be read back. A type whose base type has no registrations at all is still written with its identifier, so a model that only travels to a client keeps working without a registry. Arc writes an enum as an integer: the result of `value()` when the enum implements `ArcEnum`, and the ordinal otherwise. Reading accepts that integer or the member name matched case-insensitively, and rejects anything else. ### Polymorphic property declarations Declare polymorphic properties as an interface or abstract base, not an ordinary concrete base class. KSP reports `ARCKSP0305` when a collected command, read-model, interface, or reachable DTO property declares a concrete class with a distinct, nonabstract `@DerivedType` descendant visible in the current compilation. The check also applies to nullable properties and the element type of supported collections and arrays. Annotating the concrete base itself with `@DerivedType` does not exempt it when it has descendants. A concrete leaf remains legal, as does ordinary inheritance without annotated descendants. Visiting a superclass to collect inheritance metadata is not itself a prohibited property use. Existing map restrictions are unchanged. This authoring restriction prevents a concrete base instance from writing JSON without `_derivedTypeId` that a registered polymorphic base requires on read. It is a property-use check, not a guarantee for every runtime registry configuration: changing a declaration to an interface or abstract base does not validate arbitrary multilevel registrations. Root read-model types are not rejected solely for being concrete polymorphic bases. KSP checks source descendants, including those generated in later processing rounds and source descendants of dependency bases; binary-only descendants or manually registered types not visible to KSP are outside this check. Verify those runtime configurations separately. Source-visible derived leaves generated in later KSP rounds are included in the reachable metadata graph even when they are not commands or read models themselves. Arc refreshes existing interface-property derivative associations with that graph, allowing generated proxies to include the leaf model and its discriminator mapping. An unrelated annotated hierarchy is not included merely because it has `@DerivedType` annotations. ### Runtime derived-type dispatch Populate the `DerivedTypeRegistry` before reading registered base types with `ArcObjectMapper`. On Arc’s ordinary discriminator path, runtime dispatch resolves `_derivedTypeId` only within the declared base type’s registrations; it does not infer self registrations, search other bases, or follow a transitive chain of identifiers. On that path, a non-null value must be an object with a textual, known identifier whose registered target is assignable to that base. Explicit JSON `null` remains `null`. On this ordinary Arc path, manual runtime registrations support multilevel inheritance. For an abstract `Root`, an annotated concrete `Middle` extending `Root`, and an annotated `Leaf` extending `Middle`, register each intended pair explicitly: `Root` → `Middle`, `Root` → `Leaf`, and `Middle` → `Leaf`. Reading `Root` with the middle identifier creates exactly `Middle`, even though `Middle` is also a registered base. Reading either `Root` or `Middle` with the leaf identifier creates `Leaf`. Reading `Middle` with its own identifier still fails unless `Middle` → `Middle` was explicitly registered. Arc consumes only the current object’s discriminator before binding the selected target. Nested properties and collection elements independently validate their identifiers against their own declared bases. Compatible generic type bindings and property-specific Jackson bean configuration are retained during target binding. On this ordinary path, when a registered target declares no type parameters of its own and the requested base is parameterized, Arc also checks the target’s fixed inherited bindings before looking up or invoking its deserializer. A definite contradiction, such as a target extending `Base` requested as `Base`, fails during reading with `JsonMappingException` naming the target, requested base, and conflicting binding path rather than returning a value that fails at typed payload access. The check recursively compares available generic arguments, collection elements, map keys and values, reference contents, and array components, projecting compatible subtypes onto the requested raw type before comparing their arguments. This is read compatibility, not mutable generic invariance: a fixed `String` binding can be read as `String`, `Object`, or `CharSequence`, and a fixed `List` as `Collection`. Raw or unconstrained requests are allowed; absent or unresolved actual metadata, including Jackson’s indistinguishable `Object` bindings and erased wildcard constraints, cannot establish a definite contradiction and are not rejected by this check. It does not inspect payload values, validate custom deserializer output or application-defined type policies, or replace Jackson’s existing specialization checks for targets with their own type parameters. JSON `null` bases remain `null`; a null payload does not excuse a definite binding contradiction. These are runtime mapper capabilities, not changes to KSP model authoring: the `ARCKSP0305` concrete-property restriction above and existing generic model restrictions remain in force. The Arc wire format is unchanged: annotated objects carry one `_derivedTypeId` alongside their ordinary properties. Updating readers on the ordinary Arc path retain replacement-read semantics rather than mutating an existing value, and strict Jackson merge configuration rejects those updates. Existing native Jackson type dispatch, configured through `@JsonTypeInfo` or default typing, remains Jackson-owned rather than constrained by the Arc registry. The Arc exact-identifier and assignability checks above apply to its ordinary discriminator path, not to native subtype selection. For example, a native name resolver can select a subtype absent from the Arc base’s registrations without `_derivedTypeId`; adding `_derivedTypeId` can instead fail as an unknown property on that selected bean. Preserving this existing dispatch is not an Arc round-trip guarantee or a claim that all native combinations are supported, safe, or wire compatible. Separate hardening is tracked in issue #120. ### Flags combinations `@Flags` does not give an enum the ability to carry a combination. A JVM enum constant is one named value, so `Read or Write` has a constant to deserialize into only when the enum declares one. A generated client can compose such a value — the emitted `all` constant exists to be combined with `|` — and the server answers with the ordinary safe `malformedRequest` envelope on both a command body and a query argument, without disclosing the enum type. Arc .NET draws the same boundary, because its enum converter gates reads on whether the integer is a defined member. Two shapes carry a combination: * Declare the combination as its own member, which gives it a value to write and a constant to read into. * Declare a set of the enum, which writes an array of member wire values and accepts any combination: ```kotlin @Flags enum class Permission(private val wireValue: Int) : ArcEnum { None(0), Read(1), Write(2); override fun value(): Int = wireValue } data class Grant(val permissions: Set) ``` A `Grant` holding `Read` and `Write` writes `{"permissions":[1,2]}` and reads back into the same set. ### Polymorphic reads `@DerivedType` on its own only affects writing. Resolving `_derivedTypeId` back to a class is the job of `DerivedTypeRegistry`, and Arc never scans the classpath to fill it. Code generation records every base-to-derivative mapping it saw on the generated `ArcArtifactModule` as real class references — the interface a value is declared as, and each base class above it — and the Spring Boot starter registers all of them into its `DerivedTypeRegistry` bean before Jackson reads anything. Outside Spring, the same metadata populates a registry directly: * Java ```java static ObjectMapper mapperFor(ArcArtifactModule module) { var registry = new ConcurrentDerivedTypeRegistry(); ArcArtifactModuleRegistry.registerDerivedTypes(module, registry); return ArcObjectMapper.create(registry); } ``` * Kotlin ```kotlin fun mapperFor(module: ArcArtifactModule): ObjectMapper { val registry = ConcurrentDerivedTypeRegistry() ArcArtifactModuleRegistry.registerDerivedTypes(module, registry) return ArcObjectMapper.create(registry) } ``` A hierarchy that arrives from a dependency binary Arc’s code generation never processed has no generated mapping. Register one with a `DerivedTypeRegistrar` bean; registrars run after the generated registrations, in Spring `@Order`: * Java ```java @Bean DerivedTypeRegistrar externalShapes() { return registry -> registry.register(Shape.class, ExternalCircle.class); } ``` * Kotlin ```kotlin @Bean fun externalShapes() = DerivedTypeRegistrar { registry -> registry.register(Shape::class.java, ExternalCircle::class.java) } ``` An application that declares its own `DerivedTypeRegistry` bean replaces Arc’s entirely, generated registrations included. Reading fails closed. An identifier the registry cannot resolve, and a value whose base type has registrations but that carries no `_derivedTypeId`, are both refused with a mapping error that names the base type; over HTTP that becomes the ordinary safe `malformedRequest` envelope. Arc .NET refuses an unresolvable identifier the same way, but returns `null` when the discriminator is absent — an intentional divergence, because a silently absent property is harder to diagnose than a rejected request. # Configuration reference > Arc Spring Boot properties, Gradle plugin extension fields, defaults, coordinates, and tasks. ## Published identities | Component | Identity | | --- | --- | | Runtime | `io.cratis:arc:` | | KSP processor | `io.cratis:arc-ksp:` | | Spring Boot integration | `io.cratis:arc-spring-boot-starter:` | | Spring Data JPA | `io.cratis:arc-spring-data-jpa:` | | Spring Data MongoDB | `io.cratis:arc-spring-data-mongodb:` | | OpenAPI | `io.cratis:arc-openapi-spring-boot-starter:` | | Observability | `io.cratis:arc-observability-spring-boot-starter:` | | Chronicle integration | `io.cratis:arc-chronicle-spring-boot-starter:` | | Testing | `io.cratis:arc-testing:` | | Gradle plugin | Plugin ID `io.cratis.arc`; implementation artifact `io.cratis:arc-gradle-plugin:` | Local builds default to `0.0.0-SNAPSHOT`; `-Pversion=` overrides it. A local coordinate is not automatically available in a consumer build until it is published or supplied by a composite build. ## Arc tenancy options `Source` exposes immutable `TenancyOptions` for request adapters and tests. The defaults match Arc on .NET: | Option | Default | Meaning | | --- | --- | --- | | `headerName` | `x-cratis-tenant-id` | Case-insensitive request header used by header resolution and subdomain fallback. | | `queryParameterName` | `tenantId` | Case-sensitive query parameter. | | `claimType` | `tenant_id` | Claim read from explicit context claims, then principal claims. | | `baseDomain` | Empty | Required by `SubdomainTenantIdResolver`; must contain at least two valid DNS labels and cannot be an address. | | `fixedTenantId` | `development` | Value returned by fixed and development resolvers. | Every resolver receives a `TenantResolutionContext` containing explicit headers, query parameters, host, claims, and principal. No resolver reads thread-local or ambient request state. `CompositeTenantIdResolver` tries resolvers in list order and returns the first nonblank result, so declaration order is the complete precedence rule. `SubdomainTenantIdResolver` accepts exactly one DNS label before `baseDomain` and otherwise falls back to `headerName`. For example, `acme.myapp.com` resolves `acme` for `myapp.com`; `myapp.com`, `one.two.myapp.com`, unrelated hosts, invalid labels, and address literals use the fallback. ## Spring Boot properties All properties use the `cratis.arc` prefix. | Property | Default | Meaning | | --- | --- | --- | | `endpoints.route-prefix` | `api` | Prefix for conventional routes. | | `endpoints.segments-to-skip-for-route` | `0` | Leading package segments omitted from routes. Must be nonnegative. | | `endpoints.include-command-name-in-route` | `true` | Includes command names in conventional routes. | | `endpoints.include-query-name-in-route` | `true` | Includes query names in conventional routes. | | `endpoints.enable-query-http-method` | `true` | Adds RFC QUERY alongside GET for one-shot and observable snapshot routes. | | `correlation-header` | `X-Correlation-ID` | Java-friendly request and response header name used consistently by every route in the host. Must not be blank. | | `correlation-enabled` | `true` | Registers the host-wide correlation filter for `/*`. Disable it when the application owns correlation itself. | | `tenant-header` | `x-cratis-tenant-id` | Backward-compatible alias and default for `tenancy.header-name`. | | `tenancy.resolvers` | `header` | Ordered resolver precedence. Values are `fixed`, `header`, `query`, `claim`, `subdomain`, and `development`. | | `tenancy.required` | `false` | Rejects unresolved command, query, observable, and identity requests with 400 when enabled. | | `tenancy.header-name` | Unset | Case-insensitive header name; an unset value uses `tenant-header`. | | `tenancy.query-parameter-name` | `tenantId` | Exact case-sensitive query parameter name. | | `tenancy.claim-type` | `tenant_id` | Principal claim used for resolution and authenticated membership checks. | | `tenancy.base-domain` | Empty | Valid multi-label DNS base domain required by subdomain resolution. | | `tenancy.fixed-tenant-id` | `development` | Value returned by fixed and development resolution. | | `tenancy.constrain-to-authenticated-claims` | `true` | When authenticated claims of `claim-type` exist, the resolved tenant must be one of them. | | `spring-data.jpa.command-transactions-enabled` | `false` | Opts a verified fixed JPA persistence unit into an imperative, thread-bound command transaction scope. Dynamic tenant units are never auto-enrolled. | | `spring-data.mongodb.command-transactions-enabled` | `false` | Opts a verified fixed MongoDB template with an identity-aligned manager into an imperative, thread-bound command transaction scope. Dynamic tenant stores are never auto-enrolled. | | `request-timeout` | `30s` | Async servlet request and bounded first-result ceiling. | | `coroutine-parallelism` | `4` | Maximum actively executing Arc request coroutines; must be greater than zero. | | `coroutine-queue-capacity` | `256` | Maximum admitted Arc operations waiting for execution; zero permits no waiting. | | `overload-retry-after-seconds` | `5` | `Retry-After` value when general Arc request admission is exhausted. | | `maximum-request-body-bytes` | `1048576` | Maximum command or QUERY body, enforced for declared and streamed lengths. | | `command-scope-completion-timeout` | `5s` | Independent cooperative timeout for each best-effort command execution-scope completion. | | `platform-identity.enabled` | `false` | Opts into the optional Spring Security platform-header bridge. The default trust policy denies all ingress; supply an application `ArcPlatformIdentityTrust` and secure, header-rewriting ingress. With no application chain, all routes require authentication and CSRF stays enabled; see [platform security](/arc/backend/kotlin/guides/security/#establish-trust-before-accepting-platform-headers). | | `identity-cookie-secure-policy` | `auto` | `always`, `never`, or `auto`; auto secures HTTPS and every non-development profile. | | `expose-exception-details` | Profile-derived | When unset, enabled only for `dev`, `development`, or `local` profiles. | | `observable-queries.wait-for-first-result-timeout` | `30s` | Maximum default wait for the first HTTP snapshot result. A request may shorten it. | | `observable-queries.keep-alive-interval` | `30s` | Idle interval between hub `Ping` messages; zero disables heartbeats. | | `observable-queries.connection-timeout` | `30m` | Maximum SSE connection lifetime; zero disables the transport timeout. | | `observable-queries.maximum-connections` | `1000` | Concurrent direct and multiplexed streaming connections. Exhaustion returns 503. | | `observable-queries.maximum-subscriptions-per-connection` | `100` | Active subscriptions on one hub connection. Exhaustion returns 429 for SSE POST. | | `observable-queries.outbound-buffer-capacity` | `64` | Pending outbound frames per connection before fail-closed cleanup. | | `observable-queries.maximum-inbound-message-size` | `65536` | Maximum WebSocket text message size in bytes. | | `observable-queries.overload-retry-after-seconds` | `5` | `Retry-After` value on 503/429 overload responses. | | `observable-queries.web-socket-enabled` | `true` | Registers WebSocket routes when Spring WebSocket is on the classpath. | | `observable-queries.allowed-origins` | *(empty)* | Browser origins allowed to open an observable-query WebSocket. Empty means same-origin only. | A dev server needs its origin named Spring answers a cross-origin WebSocket handshake with `403`, and a browser reports that as a socket that never opens rather than as an error you can see. Serving the page from a dev server on another port makes every handshake cross-origin, so set `cratis.arc.observable-queries.allowed-origins` to that origin — `http://localhost:5173`, for example. Leave it empty in a deployment: the handshake is not subject to the same-origin policy `fetch` obeys, so this check is what stops another site from opening a socket with your visitor’s cookies attached. The optional observability starter adds properties under `cratis.arc.observability`: | Property | Default | Meaning | | --- | --- | --- | | `enabled` | `true` | Decorates Arc execution contracts when a non-no-op `ObservationRegistry` bean exists. | | `correlation-baggage-enabled` | `true` | Places command and query correlation identifiers in OpenTelemetry baggage when its API is available. | | `correlation-logging-enabled` | `true` | Places command and query correlation identifiers in SLF4J MDC when its API is available. | Correlation identifiers are observation context, baggage, and logging values, not metric or span tags. The starter never records tenant identifiers, user identifiers, command values, query arguments, claims, headers, or cookies. ## Gradle plugin extension `cratisArc` fields: | Field | Default | Meaning | | --- | --- | --- | | `moduleName` | Project name | Stable KSP artifact module name; cannot be blank. | | `dependencyVersion` | Plugin implementation version | Version for managed `arc` and `arc-ksp` dependencies. | | `manageDependencies` | `true` | Adds missing runtime and KSP dependencies. | | `endpoints.routePrefix` | `api` | Proxy route prefix. | | `endpoints.segmentsToSkip` | `0` | Package segments omitted from proxy routes. | | `endpoints.includeCommandNames` | `true` | Includes command names. | | `endpoints.includeQueryNames` | `true` | Includes query names. | | `endpoints.enableQueryHttpMethod` | `true` | Permits QUERY selection in proxies. | | `proxies.enabled` | `true` | Enables generation when an output directory exists. | | `proxies.outputDirectory` | Unset | Generated TypeScript directory. An unset value skips generation. | | `proxies.removeStaleGeneratedFiles` | `true` | Removes only stale Arc-marked files. | | `proxies.segmentsToSkip` | `0` | Package segments omitted from generated import layout. | | `proxies.useProxyFileSuffix` | `false` | Names generated type files `Name.proxy.ts` instead of `Name.ts`. | | `proxies.typeMappings` | Empty | Raw `=[=]` entries, consulted before the built-in type map. Prefer `proxies.mapType(typeName, typeScriptType)` or `proxies.mapType(typeName, typeScriptType, npmPackage)`. | | `proxies.packageMappings` | Empty | JVM package to npm package mappings; model types under a mapped package are imported instead of generated, and the longest matching package wins. Prefer `proxies.mapPackage(javaPackage, npmPackage)`. | The [TypeScript proxy options reference](/arc/backend/kotlin/reference/typescript-proxy-options/) compares each option with the Arc .NET generator. Resolver configuration is validated at startup: the resolver list cannot be empty or contain duplicates, selected resolver options cannot be blank, fixed/development identifiers must be nonblank, and subdomain configuration must have a valid base domain. `CompositeTenantIdResolver` follows the listed order exactly. An application-defined `TenantIdResolver` or `TenantAccessEvaluator` bean replaces the default with Spring Boot’s normal `@ConditionalOnMissingBean` behavior. The host captures case-insensitive headers, each query parameter’s first value, host, principal, and claims into an explicit `TenantResolutionContext` at request or subscription entry. Resolution is performed once and the resulting value is supplied as both `tenantId` and `tenantNamespace`; no thread-local state is used. Optional unresolved tenancy remains `null` for backward compatibility. Fixed or development resolution must be configured explicitly before either can supply a default. Access denial is generic and returns 403. The plugin applies Kotlin/JVM `2.4.20` and KSP `2.3.12` (KSP2), targets JDK 17, and treats Kotlin and Java warnings as errors. Use Gradle 8.14.4, the repository’s wrapper baseline. See the [compiler compatibility notes](/arc/backend/kotlin/get-started/#compiler-compatibility) before retaining older consumer compiler or runtime pins. `generateArcProxies` depends on main classes, KSP, and resources, and `build` depends on it. Use the standard active JDK selection through `JAVA_HOME` and `PATH`; the repository requires no machine-specific absolute JDK path. KSP diagnostics use stable `ARCKSP` codes. Configuration, command, query, proxy-shape, validation-metadata, enum-wire-value, and interoperability diagnostics are cataloged in `CodeGeneration/KSP/DIAGNOSTICS.md`; errors stop generation, while warnings identify risky but compilable conventions. Published Kotlin APIs use checked `.api` baselines. The root `apiCheck` gate covers `arc`, KSP, the Gradle plugin implementation artifact, every published integration, and testing support; contract tests and runnable samples are intentionally unpublished and excluded. An API change therefore requires an explicit baseline update rather than silently changing compiler or plugin contracts. The temporal/UUID proxy slice changes generated TypeScript source types. Recursive shape metadata was already present in manifest format 5, and source summaries were introduced in format 6. Format 7 added optional typed command `eventMetadata`; current format 8 requires explicit property `ignoreValidation` metadata. Immutable public JVM shape descriptors, earlier constructor descriptors, and compatibility getters remain available. See the [format-8 migration and Jakarta factory contract](/arc/backend/kotlin/reference/validation/#manifest-format-8-migration). Because the reader accepts exactly the current version, a dependency built against an earlier format must be rebuilt. The runtime proxy harness has five wired unit tests plus 35 behavioral runtime tests. It runs four ignore-validation tests, four shared-fluent tests, and 15 general tests in separate UTC Node processes, then six calendar tests in a separate UTC process and the same six in a separate `America/Los_Angeles` process. Counts are parsed from each TAP summary, which must report its exact expected test/pass total and zero fail, cancelled, skipped, or todo results. Spring process-spawn errors fail cleanly instead of hanging startup. Repository proxy verification has three distinct gates: deterministic generation plus strict TypeScript compilation with `verbatimModuleSyntax`, comparison with a repository-local semantically normalized .NET-derived expected fixture intended for source control, and `:ContractTests:typeScriptRuntimeTest` against the executable Kotlin Spring Boot sample. The differential compares sorted paths for all regular files and untouched JVM bytes, including headers, against prepared expected bytes. Only the expected side receives LF/trailing-whitespace handling, FixtureModel quote/indent preparation, CreateFixtures quote/import/layout/hook preparation with exact-site suppression, and five literal type-only import rewrites for `verbatimModuleSyntax`. An independent literal source table, cross-checked against fixture descriptors, validates 16 artifacts; queries use their declaring model. Uppercase SHA-256 headers are reconstructed from prepared expected bodies, never actual output, and three indexes stay headerless. Historical capture-time namespace/query-name casing and removed timestamps/hashes remain embedded in the fixture. `SetCommandValues`, `ClearCommandValues`, and query helper types such as `PerformQuery`, `SetSorting`, `SetPage`, `SetPageSize`, and `ChangeSet` become type-only imports. One additional expected-side correction at `Commands/CreateFixtures.ts` changes `Command` to `Command`. The captured command already calls `super(FixtureModel, true)`, so its scalar generic contradicts enumerable runtime behavior. Only `Models/Observe.ts` also prepares the zero-space blank line immediately before the four-space-indented `filter: string;` member in exactly one known `ObserveParameters` block as four spaces; `ObserveOne.ts` is excluded. Missing/duplicate correction anchors and misplaced suppressions fail preparation. The .NET-derived `FixtureModel.labelsByCategory` capture already uses `@field(Object)` and `Record`; no dictionary-shape rewrite is applied to it, although its file receives the documented formatting preparation. It proves that one string-key/string-value Record fixture, not non-string keys, nullable entries, typed model values, `ValueMap`, or broader dictionary parity. `Contracts/Shape.ts` is a class, not interface-emission proof. No normalization transforms JVM output. Capture SDK/tool versions remain unverified; this remains a normalized-fixture drift gate, not raw .NET-output equivalence, and overall parity remains Partial. The current .NET fixture contains no `Guid`, `DateOnly`, or `TimeOnly`, so focused generator and contract tests cover that mapping. See [Generate TypeScript proxies](/arc/backend/kotlin/guides/typescript-proxies/) for the complete rewrite inventory, source table, independent hash contract, and remaining capture-time reproducibility limitation. ## Binary validation annotation inputs The Arc plugin supplies KSP option `arc.validationClasspath` for each compilation: sorted absolute file URIs for the real compile-classpath directories/JARs, joined by `|`. The same file collection is a **nonincremental classpath input**, not a compile-ABI-only input. Private record field annotations can change without a public ABI change. KSP reads class-file field/accessor annotations without loading application classes, combining both sites instead of losing a field constraint when its accessor has another constraint of the same type. Manual KSP wiring needs the matching option and input declaration; see the actual Kotlin sample and ContractTests build configurations linked from [manual validation tooling](/arc/backend/kotlin/reference/validation/#manual-build-tooling). Do not pass an unrelated runtime classpath. Missing field evidence or conflicting/unreadable record definitions fail with `ARCKSP0311`; malformed input locations fail with `ARCKSP0001`. This option creates no new metadata resource and does not replace the format-8 manifest or the independent fluent declaration fingerprint. `ArcFluentValidationNativeFunctionalTest` proves private-field edits, unchanged-consumer invalidation, and fresh-output equivalence; the existing response-handler native fixture also proves ordinary unannotated binary records remain supported. ## Dependency response-handler metadata Arc KSP exports validated local `@HandlesCommandResponseValues` declarations to `META-INF/cratis/arc-response-handlers/.json`. This is a separate format-1 compiler/build transport, separate from the format-8 artifact manifest. A handler-only producer emits the declaration resource without manufacturing an artifact module, service entry, or artifact manifest. Imported declarations are never re-exported. The producer document has exactly `formatVersion`, `moduleName`, and `handlers`: ```json {"formatVersion":1,"moduleName":"Audit","handlers":[{"handlerTypeName":"example.AuditHandler","handledTypeNames":["example.AuditEntry"]}]} ``` The aggregate index has exactly `formatVersion: 1` and `modules: [producer documents]`; no dependencies produces `{"formatVersion":1,"modules":[]}`. Output is canonical UTF-8, with fixed property order, sorted modules, handlers, and handled names, and one terminal newline. Paths and timestamps are not serialized. Identical module documents deduplicate; the same module name with different declarations fails. Identical handler claims may appear in different modules, but conflicting claims fail. Different handlers may handle the same value. Duplicate handler entries or handled names within one document, duplicate JSON fields, unknown fields, malformed types, and unsupported versions fail closed. Binary export initially supports public top-level handlers and public top-level handled types with ASCII canonical names. Other supported source-local declarations remain source-local. Consumers resolve indexed names through the current KSP resolver each round and verify their annotation and supported Kotlin/blocking/async handler SPI. Missing classes or disagreement fail with `ARCKSP0102`, naming the binary resource rather than inventing a source location. Invalid or unreadable `arc.responseHandlerMetadata` options fail with `ARCKSP0001`. The option accepts an absolute `file:` URI, including percent-encoded spaces; omitting it retains source-only discovery. The Arc plugin registers `extractMainArcResponseHandlerMetadata`, `extractTestArcResponseHandlerMetadata`, and corresponding tasks for other JVM compilations. Each resolves its compilation’s dependency artifacts as JARs, not its own outputs. Main and test indexes are independent. Index wiring does not add a processor dependency to additional compilations: for generated test artifacts, also add `io.cratis:arc-ksp:` to `kspTest`. `ExtractArcResponseHandlerMetadata.dependencyArtifacts` is `@Classpath` because resource changes matter, and the output is written only when its bytes change. `ArcResponseHandlerMetadataArgumentProvider.metadataFile` is path-insensitive. The index is also a **nonincremental task input**: a changed index requires complete KSP reclassification in that compilation and can rerun other processors there. Ordinary source edits and no-op builds retain the existing incremental behavior. Runtime registration and `canHandle` remain separate; missing or nonmatching runtime handlers still fail closed. For manual Gradle/KSP builds, put `io.cratis:arc-gradle-plugin:` on the buildscript classpath without applying `io.cratis.arc`. The following Groovy wiring is exercised by the native producer/consumer contract; retain your runtime, KSP, and repository declarations. Use the same published Arc version throughout (the local workspace version is `0.0.0-SNAPSHOT`, available only after supplying its artifacts). ```groovy buildscript { repositories { mavenCentral(); gradlePluginPortal() } dependencies { classpath 'io.cratis:arc-gradle-plugin:0.0.0-SNAPSHOT' } } plugins { id 'org.jetbrains.kotlin.jvm'; id 'com.google.devtools.ksp' } repositories { mavenCentral() } dependencies { implementation 'io.cratis:arc:0.0.0-SNAPSHOT' ksp 'io.cratis:arc-ksp:0.0.0-SNAPSHOT' } kotlin { jvmToolchain(17) } ksp { arg('arc.moduleName', 'Consumer') } def extract = tasks.register('extractHandlers', io.cratis.arc.gradle.ExtractArcResponseHandlerMetadata) { dependencyArtifacts.from(configurations.compileClasspath.incoming.artifactView { attributes.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, LibraryElements.JAR)) }.files) outputFile.set(layout.buildDirectory.file('handler index/main.json')) } tasks.withType(com.google.devtools.ksp.gradle.KspAATask).configureEach { if (name == 'kspKotlin') { def provider = objects.newInstance(io.cratis.arc.gradle.ArcResponseHandlerMetadataArgumentProvider) provider.metadataFile.set(extract.flatMap { it.outputFile }) commandLineArgumentProviders.add(provider) inputs.file(provider.metadataFile).withPropertyName('arcResponseHandlerMetadata') .withPathSensitivity(PathSensitivity.NONE) } } ``` Repeat this task-local wiring with the corresponding dependency configuration and a distinct output for each additional compilation. Do not use a global KSP argument provider for all source sets, include the compilation’s own classes, or mark the index input incremental. ## Manual KSP setup Without the plugin, apply Kotlin/JVM and `com.google.devtools.ksp`, add `io.cratis:arc` to `implementation`, add `io.cratis:arc-ksp` to `ksp`, and set `arc.moduleName` in the KSP extension. Kotlin Spring applications also apply Kotlin’s Spring plugin. Add the Arc Spring starter and `spring-boot-starter-webmvc` for HTTP hosting. Spring Boot 4 and Arc use the same Jackson 3 mapper. The starter contributes Arc’s module and builder customizer, so conventional MVC controllers and generated Arc endpoints share the same naming, inclusion, temporal, enum, concept, and polymorphism policy without an additional property. An application-supplied mapper remains authoritative; construct its Arc-configured replacement with `ArcObjectMapper.configure(mapper)` because Jackson 3 mappers are immutable. ## Jackson 3 migration Arc’s JSON API uses Jackson 3.1.x. Runtime types moved from `com.fasterxml.jackson` to `tools.jackson`, including `ObjectMapper`, `JsonNode`, modules, serializers, deserializers, and naming strategies. This is binary- and source-breaking for applications that called `ArcObjectMapper`, constructed `ChangeSetComputer` with a mapper, consumed introspection schemas, supplied an OpenAPI mapper, or referenced `ArcJacksonModule` directly. Replace those imports with their `tools.jackson` equivalents. Jackson annotations are the deliberate exception: Jackson 3 still uses `com.fasterxml.jackson.annotation`, so application model annotations do not change package. Separate Java-time modules are no longer required because Jackson 3 embeds Java-time support. `ArcObjectMapper.configure(mapper)` no longer mutates its argument. Jackson 3 mappers are immutable, so capture the configured copy: * Java ```java ObjectMapper mapper = ArcObjectMapper.configure(JsonMapper.builder().build()); ``` * Kotlin ```kotlin val mapper = ArcObjectMapper.configure(JsonMapper.builder().build()) ``` The mapper must be a JSON `JsonMapper`; Jackson 3 does not permit a generic mapper to be repurposed for another format. # KSP diagnostics > Every compile-time diagnostic Arc's annotation processor can report, what it means, and whether it stops the build. Arc discovers your commands, read models and validation rules while the JVM compiler runs, through a KSP annotation processor. When it cannot make sense of a declaration it reports a diagnostic against that declaration rather than failing later at runtime or silently generating nothing. Every diagnostic carries a stable code. **Errors stop code generation**, so the build fails and no artifact manifest is written. **Warnings identify a convention that compiles but is probably not what you meant** — most often a declaration Arc will ignore, which shows up later as a missing endpoint or a proxy that was never generated. Codes are grouped by the artifact they concern: `01xx` commands, `02xx` queries and read models, `03xx` proxy generation and validation, `04xx` interoperability. The C# implementation reports equivalent conventions through Roslyn analyzers with `ARC` codes; see [code analysis](/arc/backend/csharp/code-analysis/). The two sets are not numbered alike, because each follows what its own compiler can see. ## Diagnostics | Code | Severity | Description | | --- | --- | --- | | `ARCKSP0001` | Error | Invalid KSP configuration | | `ARCKSP0100` | Warning | Command-like type is missing @Command | | `ARCKSP0101` | Error | Unsupported command declaration | | `ARCKSP0102` | Error | Invalid command handle function | | `ARCKSP0103` | Error | Invalid command provide function | | `ARCKSP0104` | Error | Unsupported command method parameter | | `ARCKSP0105` | Error | Unsupported command method return type | | `ARCKSP0106` | Error | Ambiguous command key | | `ARCKSP0107` | Warning | Provided value is not consumed by handle | | `ARCKSP0108` | Error | Conflicting authorization metadata | | `ARCKSP0109` | Error | Ambiguous command response values | | `ARCKSP0110` | Error | Invalid command event metadata | | `ARCKSP0200` | Error | Unsupported read model declaration | | `ARCKSP0201` | Error | Invalid query function | | `ARCKSP0202` | Error | Ambiguous query overload | | `ARCKSP0203` | Error | Unsupported query parameter | | `ARCKSP0204` | Error | Unsupported query return type | | `ARCKSP0205` | Error | Query transport and return type disagree | | `ARCKSP0206` | Error | Ambiguous or duplicate query route | | `ARCKSP0207` | Error | Duplicate fully qualified query name | | `ARCKSP0208` | Error | Invalid query infrastructure parameter | | `ARCKSP0209` | Error | Unsupported Kotlin query parameter default | | `ARCKSP0210` | Error | Invalid host query adapter shape | | `ARCKSP0300` | Error | Unsupported generated proxy model shape | | `ARCKSP0301` | Error | Invalid or unrepresentable Jakarta validation metadata | | `ARCKSP0302` | Error | Ambiguous or unprovable Arc enum wire value | | `ARCKSP0303` | Error | Missing or blank @DerivedType identifier | | `ARCKSP0304` | Error | Unsupported @DerivedType declaration target | | `ARCKSP0305` | Error | Concrete polymorphic base used as a property type | | `ARCKSP0306` | Error | Unsupported @ExportedType declaration target | | `ARCKSP0307` | Error | Unsupported identity details provider declaration | | `ARCKSP0308` | Error | Unsupported fluent validation declaration | | `ARCKSP0309` | Error | Invalid or unrepresentable fluent validation rule | | `ARCKSP0310` | Error | Missing or conflicting fluent validation compiler metadata | | `ARCKSP0311` | Error | Unsupported or ambiguous validation ignore member | | `ARCKSP0400` | Warning | Java/Kotlin interoperability hazard | | `ARCKSP9999` | Error | Unclassified Arc KSP diagnostic | ## When a diagnostic fires A diagnostic names the declaration that caused it. Fix the declaration rather than suppressing the code: Arc only generates artifacts it can fully describe, so a suppressed error means the endpoint, proxy or validation rule you expected will not exist. If you hit `ARCKSP0001` the processor itself is misconfigured rather than your code being wrong — check that the `io.cratis.arc` Gradle plugin is applied and that KSP is on the compile path, as described in [getting started](/arc/backend/kotlin/get-started/). # Glossary > One precise line per Arc.Kotlin term, cross-referenced to the guide or reference page that covers it in full. ## Commands and queries * **Command** — a class or record annotated `@Command` expressing an imperative intent; its public `handle` method produces a response, appends events, or both. See [Create and validate commands](/arc/backend/kotlin/guides/commands/). * **Handle** — the public instance method KSP requires on a `@Command`. May be synchronous, `suspend`, or return a Java `CompletionStage`. * **Provide** — the optional method that runs after validation and authorization, before `handle`, to fetch or compute data `handle` needs. See [Prepare handler values](/arc/backend/kotlin/guides/commands/#prepare-handler-values). * **Command key (`@CommandKey`)** — the property that identifies the command’s subject; required for a command to return a plain Chronicle event. See [Return an event](/arc/backend/kotlin/guides/chronicle/#return-an-event). * **Read model** — a class or record annotated `@ReadModel` whose static or companion methods are queries. See [Expose one-shot and observable queries](/arc/backend/kotlin/guides/queries/). * **Query** — a static (Java) or `@JvmStatic` companion (Kotlin) method on a `@ReadModel`. One-shot queries return a value; observable queries return `Flow` or `Flow.Publisher`. * **Observable query** — a query whose return type is `Flow`/`Flow>` or `Flow.Publisher`/`Publisher>` (or, with the optional `arc-rxjava3` artifact, an RxJava 3 type). Arc hosts it as an HTTP snapshot, direct SSE, direct WebSocket, or a multiplexed hub. See [Declare observable queries](/arc/backend/kotlin/guides/observable-queries/). * **`@FromServices`** — marks a query parameter as a dependency resolved from Spring, distinguishing it from an unannotated caller argument. ## Generated artifacts * **KSP** — [Kotlin Symbol Processing](https://kotlinlang.org/docs/ksp-overview.html), the compiler plugin that reads `@Command`/`@ReadModel` declarations and generates handlers, performers, and the artifact manifest at compile time. Arc’s processor is `io.cratis:arc-ksp`. See [Configuration reference](/arc/backend/kotlin/reference/configuration/) for the application-facing surface. * **Artifact manifest** — the language-neutral JSON document (`META-INF/cratis/arc/*.json`) KSP emits per module, consumed by the TypeScript proxy generator. See [Configuration reference](/arc/backend/kotlin/reference/configuration/). * **Proxy** — the generated TypeScript client for a command, query, model, interface, or enum, carrying a `// @generated by Cratis` header. See [Generate TypeScript proxies](/arc/backend/kotlin/guides/typescript-proxies/). * **`ARCKSP` diagnostic** — a stable compile-time error or warning code KSP reports, for example `ARCKSP0101` (unsupported command declaration). Cataloged in [KSP diagnostics](/arc/backend/kotlin/reference/diagnostics/). * **Concept (`ConceptAs`)** — a Kotlin class or Java record implementing `io.cratis.fundamentals.concepts.ConceptAs` from `io.cratis:fundamentals` that wraps a single scalar value. `io.cratis.arc.concepts.ConceptAs` is a deprecated compatibility interface. Serializes as its underlying value, not as a wrapper object, across commands, queries, models, validation, TypeScript, OpenAPI, and Chronicle command keys. ## Validation * **`ConceptValidator`** — a reusable, server-only rule applied everywhere a specific concept type appears in a command or query graph. See [Add validation](/arc/backend/kotlin/guides/commands/#add-validation). * **`ModelValidator`** — a server-only rule for one exact runtime model class, applied to command roots, nested models, and supplied query arguments. See [Reuse model validation](/arc/backend/kotlin/guides/commands/#reuse-model-validation). * **`FluentModelValidator`** — a bounded, literal-rule validator whose rules are also emitted into the generated TypeScript client, so the same constraint runs in the browser and on the server. See [Share fluent validation](/arc/backend/kotlin/guides/validation/). * **`@IgnoreValidation`** — cuts one member’s validation edge before access while leaving serialization and binding unchanged. See [Ignore a validation member edge](/arc/backend/kotlin/reference/validation/#ignore-a-validation-member-edge). ## Identity, tenancy, and transport * **`AuthenticationHandler`** — an ordered Kotlin or Java (`AsyncAuthenticationHandler`) bean that inspects a request and returns an authenticated, failed, or anonymous result. See [Register an authentication handler](/arc/backend/kotlin/guides/security/#register-an-authentication-handler). * **`TenantIdResolver`** — resolves the current request’s tenant from a header, query parameter, claim, subdomain, or fixed/development value. See [Configuration reference](/arc/backend/kotlin/reference/configuration/). * **`CommandResult` / `QueryResult`** — the JSON envelopes every command and query response uses. See the [HTTP contract reference](/arc/backend/kotlin/reference/http-contract/). * **Correlation ID** — the `X-Correlation-ID` value the Spring Boot starter establishes once per request, for every route in the host, Arc-owned or not. See [Correlation](/arc/backend/kotlin/reference/http-contract/#correlation). ## Chronicle (optional integration) * **Chronicle** — [Cratis’s event-sourcing database and runtime](https://github.com/Cratis/Chronicle), consumed here through [Chronicle.Kotlin](https://github.com/Cratis/Chronicle.Kotlin). Optional: `io.cratis:arc` has no dependency on it. See [Integrate commands and read models with Chronicle](/arc/backend/kotlin/guides/chronicle/). * **Event** — an immutable Chronicle `@EventType` fact returned from a command’s `handle` and appended to an event stream. * **Reactor** — a Chronicle artifact that observes events; `ChronicleCommandSideEffectHandler` lets one hand registered Arc command values to the real command pipeline as a side effect. See [Execute reactor command side effects](/arc/backend/kotlin/guides/chronicle/#execute-reactor-command-side-effects). * **`EventsWithConcurrencyScopes`** — a typed command response that attaches exact per-event-source Chronicle concurrency scopes to a batch of routed events. See [Attach exact concurrency scopes](/arc/backend/kotlin/guides/chronicle/#attach-exact-concurrency-scopes). ## Where to go next * [Feature parity reference](/arc/backend/kotlin/reference/parity/) — the evidence-backed status of every one of these terms in the current JVM implementation. * [Annotation reference](/arc/backend/kotlin/reference/annotations/) — the exact contract of every annotation named above. # HTTP contract - JVM conformance notes > Spring Boot specifics behind Arc's shared HTTP wire contract - paired conformance evidence, correlation filter registration, development provider endpoints, and bounded runtime behavior. The contract itself lives on the shared page Routes, headers, correlation semantics, the QUERY body, observable query transport, tenant resolution, authentication and introspection, the command and query result envelopes, HTTP statuses, the identity contract, and validation result values are the same protocol on every Arc backend. They are documented once, in the [Arc HTTP contract](/arc/http-contract/), which also records [where the C# and JVM implementations differ](/arc/http-contract/#where-the-implementations-differ). This page records only what is specific to the JVM host. ## Paired HTTP conformance evidence The explicit `:ContractTests:httpConformanceTest` gate exercises the real generated Kotlin and Java Spring Boot task-board samples alongside a repository-authored ASP.NET Core task board using published `Cratis.Arc` 22.14.0. It requires JDK 17, pinned .NET SDK 10.0.400 and runtime 10.0.11, and an explicit lifecycle-owned output location; it is not part of ordinary `build` or `check`. Run instructions live in `ContractTests/HttpConformance/README.md`. Nine cases run independently on each host: initial empty query, two distinct typed create responses, GET and RFC QUERY identifier binding, enumerable snapshots, successful validation without execution, completion response/state, malformed command JSON without mutation, and unknown-route status. The harness checks complete two-task snapshots, success flags and correlation UUIDs, QUERY `no-store`, and malformed-command validation classification without parser details. It preserves raw responses; it does not normalize them into claimed byte equality. This is selected task-board HTTP evidence, not a guarantee that all declarations or features match. The Java array and .NET plain enumerable queries do not count rows like the Kotlin iterable query, so their paging totals remain outside the common assertions. Framework-generated 404 bodies differ; only their status is compared. Authentication/authorization rejection, custom validation-rule parity, null/default handling, temporal precision, paging requests, streaming and database behavior remain outside this fixture. See the [parity reference](/arc/backend/kotlin/reference/parity/). ## Correlation filter registration and ordering The shared contract states what a correlation identifier means and how an inbound value is treated. On the JVM the Spring Boot starter additionally registers a servlet filter on `/*`, so the identifier is established for every request reaching the host, whether Arc owns the route or not. * The filter is ordered ahead of Spring Security’s filter chain and ahead of the Arc authentication filter, so both observe the identifier the request will carry. * The effective value is presented downstream as the configured request header, so an Arc endpoint and an ordinary `@RestController` on the same host observe the same value even when the client sent no header. * Servlet-thread code reads the identifier with `ArcCorrelation.of(request)`. The underlying servlet request attribute name is `ArcCorrelation.ATTRIBUTE`, and the attribute survives an asynchronous dispatch. * While the filter chain runs, the identifier is published to SLF4J MDC under `ArcCorrelation.LOGGING_KEY`, which is `arc.correlation_id`, and any value the host had there is restored afterwards. That binding belongs to the servlet thread: a suspending Arc handler resumes on another thread and takes its correlation identifier from `CommandContext` or `QueryContext` instead. * Set `cratis.arc.correlation-enabled=false` to leave correlation entirely to the application, or define a bean named `arcCorrelationFilterRegistration` to replace the registration. ## Jackson and unknown fields Arc never configures Jackson’s `FAIL_ON_UNKNOWN_PROPERTIES`, so what happens to a field the target does not declare depends on which reader sees it. | Surface | Reader | An undeclared field | | --- | --- | --- | | QUERY body envelope | Arc’s own field-set check | 400 with `malformedRequest` | | GET and QUERY client arguments | Arc’s own argument matching | 400 with `malformedRequest` | | Command body, including `/validate` | The application `ObjectMapper` bean | Accepted and dropped | | Anything read through `ArcObjectMapper.create()` | A bare Jackson mapper | Rejected | Arc validates the QUERY envelope’s field set itself, so that rejection holds no matter how the mapper is configured. A command body is handed straight to the injected Jackson 3 `ObjectMapper`. The Spring Boot starter contributes `ArcJacksonModule` and the `arcJacksonCustomizer` builder customizer, which set the naming strategy, null inclusion, named floating-point values, and date/duration text - and neither touches unknown-property handling. Conventional MVC controllers and generated Arc endpoints therefore share one mapper and wire policy, and a hosted application inherits Spring Boot’s relaxed default for command bodies. Jackson 3 mappers are immutable. `ArcObjectMapper.configure(mapper)` returns a configured copy and never changes the supplied instance; an application that contributes its own Spring mapper remains responsible for constructing that bean with the Arc configuration it wants. `ArcObjectMapper.create()` builds a bare mapper and keeps Jackson’s own strict default, so an undeclared field raises `UnrecognizedPropertyException`. Arc uses that mapper only for values it produced itself - the generated artifact manifest, change-set comparison, and the `CommandScenario` and `QueryScenario` harnesses - never to read a client request. ## JVM argument binding `UUID`, `LocalDate`, and `LocalTime` arguments use scalar UUID, date, and time strings in both GET parameters and QUERY JSON. `Duration` JSON values are ISO-8601 strings because Arc disables `WRITE_DURATIONS_AS_TIMESTAMPS` in both Core and Spring. A component object is not an alternative server shape. Arc accepts and emits `LocalTime` values with up to seven fractional digits for 100 ns compatibility; deserialization rejects eight or nine fractional digits with the safe malformed-query response, and serialization rejects values finer than 100 ns rather than rounding or truncating them. A missing Kotlin client parameter with a declared default remains absent so invocation evaluates that default. A present value is always converted, and explicit `null` is present rather than omission. Generated GET clients serialize their `Guid`, `DateOnly`, and `TimeOnly` values to those scalar strings. This server binding is distinct from the pinned shared TypeScript client’s explicit QUERY-body problem: the generated client passes `DateOnly` and `TimeOnly` component objects to native `JSON.stringify` because those classes lack `toJSON()`, rather than invoking their typed scalar serializer. Prefer GET until upstream serialization uses the typed serializer or `toJSON()`. `Guid` has `toJSON()` and is unaffected. ## Observable transport lifecycle on the JVM Default observable emission guards reconstruct [bounded per-dispatch argument copies](/arc/backend/kotlin/guides/queries/#bound-emission-guard-arguments). Unsupported or uncopyable guarded arguments terminate with the existing unauthorized result before any guard executes; this is behavioral tightening, not a wire-schema change. No guards means no argument-copy validation. Query opening and result-data ownership are unchanged. When a direct producer completes, fails during opening, or emits an unauthorized terminal result, the transport stops accepting frames and cancels heartbeats, then writes already accepted frames in order before closing. Connection capacity and health remain registered until those writes finish. Final draining uses `cratis.arc.request-timeout` (30 seconds by default) as the asynchronous request-completion budget, measured from producer completion; values below one millisecond use one millisecond rather than permitting an unlimited drain. The separate SSE connection-lifetime timeout still applies and can abort earlier. Disconnect, transport error, outbound overflow, application shutdown, or expiry of that drain budget aborts instead: queued frames are discarded, upstream work is cancelled, and connection capacity and health are released once. WebSocket outbound overflow retains close code 1013 rather than being replaced by normal closure. Lifecycle calls do not wait for blocking writes or native container closure, and native close is requested outside lifecycle locks. This bounds logical drain time, not writer-thread termination or peer acknowledgement: cancellation cannot interrupt arbitrary blocking servlet/socket I/O, and an in-progress write or native close may outlive the budget under the container’s own I/O timeouts. If an opening finishes after its subscription has already been replaced or unsubscribed, both hubs discard its opening failure instead of emitting `Error` or `Unauthorized`. Current-operation failures retain their terminal envelopes and revisions. This ownership check does not retract already queued frames or make socket writes atomic with subscription changes. Both hubs defensively capture the subscription’s original string-or-null arguments. They validate those arguments before reserving subscription state, then bind the captured input again against the same query metadata on the asynchronous runner. This preserves declared scalar and boxed-array types, omitted Kotlin defaults, and explicit nulls; it is not arbitrary JVM object cloning. Application converters therefore run twice and must be deterministic and independent of request-thread state. Canonical `Collection` and `MutableCollection` metadata can be bound by the host, but generated collection-interface query parameters remain rejected by KSP; manual metadata support does not establish generated collection support. Explicit validation severity is retained, and an omitted severity uses the subscribed query’s `TreatWarningsAsErrors` default. Spring WebSocket remains optional: when it is absent or disabled, HTTP and SSE continue to work and clients use their normal reconnect backoff. ## Development provider endpoints `/.cratis/users` and `/.cratis/tenants` aggregate all ordered coroutine provider beans and Java `AsyncUsersProvider`/`AsyncTenantsProvider` beans. The first item for each identifier wins, so the result is deduplicated by principal ID and tenant ID respectively. Empty provider sets return `[]`. Requests use Arc’s asynchronous timeout and cancellation handling; provider failures return a redacted 500 JSON error and are logged server-side. These development discovery routes allow anonymous access. ## Identity cookie secure policy The shared contract describes the `.cratis-identity` cookie. On the JVM its `Secure` attribute follows `identity-cookie-secure-policy`: `always`, `never`, or `auto`. `auto` secures it for HTTPS requests and, by default, for all non-development profiles. ## Bounded runtime behavior The Spring host admits at most `coroutine-parallelism + coroutine-queue-capacity` Arc operations without waiting for a slot; exhaustion fails closed with 503 and `Retry-After`. Command and QUERY bodies are counted while streaming and rejected with 413 at the configured `maximum-request-body-bytes` even when `Content-Length` is absent. Request, identity, authentication, development-provider, and observable waits use bounded timeouts. Observable transports separately bound physical connections, subscriptions per multiplexed connection, outbound frames, inbound WebSocket message bytes, connection lifetime, and retained subscription revision tombstones. Connection capacity exhaustion returns 503 with `Retry-After`; SSE subscription exhaustion returns 429. Overflow closes or rejects work instead of growing memory without limit. Command execution-scope completion is also independently timeout-bounded so one broken scope cannot prevent best-effort completion of earlier scopes. ## Validation and exception conversion on the JVM Command exceptions implementing [`ValidationFailure`](/arc/backend/kotlin/guides/commands/#convert-application-exceptions-to-command-validation) with a usable payload become validation results at the default command pipeline’s ordinary exception boundaries, including context/filter failures on `/validate`. Pure blocking validation returns 400 with empty exception messages and stack trace; ordinary exceptions remain 500 with the existing production/development redaction policy. Cancellation never becomes validation feedback. Parser, admission, transport timeout, and query exception behavior are unchanged; severity filtering remains [stage-specific](/arc/backend/kotlin/guides/commands/#convert-application-exceptions-to-command-validation). [Direct concept exclusions](/arc/backend/kotlin/guides/commands/#exclude-a-direct-concept-rule-edge) affect only concept rules on a matching owner/member edge. They do not bypass Jakarta or model validation or change any HTTP envelope/status policy. Command execution, command validation, one-shot queries, and observable HTTP snapshots still reject blocking feedback from remaining rules. This is not a claim that streaming openings return HTTP 400. Server-only [`ModelValidator` rules](/arc/backend/kotlin/guides/commands/#reuse-model-validation) use the same envelope and severity policy. Streaming opening failures retain their existing envelopes: direct SSE sends failed `QueryResult` data on the established HTTP stream, direct WebSocket sends a `Data` frame, and hubs send `Error` messages. Relative model paths are prefixed with the bound node path. Registration adds no manifest, client-validation, or OpenAPI schema fields. Only supplied query arguments are validated, not omitted defaults, infrastructure, or observable result data. # JVM feature parity reference > Honest implementation status for Arc on Kotlin and Java compared with broader Arc concepts. This page is the evidence behind the Kotlin and Java columns of the shared [capability matrix](/arc/capabilities/). The matrix states whether each capability exists in C#, Kotlin, and Java; this page records how the JVM implements each one, where its boundary lies, and which test, contract test, or sample proves it. The statuses below are this page’s own and are not restated there. ## Status key | Status | Meaning | | --- | --- | | Implemented | Available and covered by source tests, contract tests, integration tests, or runnable samples. | | JVM-specific | Supported with an intentionally JVM-native shape. | | Partial | Usable behavior exists, with the stated boundary still remaining. | | Not planned | Outside the Spring Boot, model-bound framework direction. | ## Feature matrix | Area | Status | Current JVM contract | | --- | --- | --- | | Model-bound commands | Implemented | `@Command` plus one public instance `handle`; KSP generates reflection-free handlers. | | Kotlin command async | JVM-specific | `suspend` handlers run in an application-owned, bounded coroutine scope. | | Java command async | JVM-specific | Generated adapters await `CompletionStage` without blocking request threads. | | Command `provide` | Implemented | Regular, `suspend`, and `CompletionStage` preparation supports ordered provided values, control short-circuiting, and service fallback without runtime method reflection. | | Tuple and alternative returns | Implemented | Kotlin `Pair`/`Triple`, `CommandProvidedValues`, `CommandResponseValues`, and `ArcOneOf` alternatives are flattened in declaration order. | | Aggregate command responses | Implemented | Runtime and KSP recursively flatten `Pair`, `Triple`, `ArcOneOf`, and nested `CommandResult` values in declaration order. Source-visible `@HandlesCommandResponseValues` declarations classify custom server-consumed leaves, and built-in event/control leaves are handled consistently. Exactly one client leaf is projected into runtime, proxy, and OpenAPI metadata; multiple client leaves fail with `ARCKSP0109`, while handled-only commands expose no fake client response. Binary annotations alone remain undiscoverable: the JVM-specific dependency declaration resource and tracked index are required (see [configuration](/arc/backend/kotlin/reference/configuration/#dependency-response-handler-metadata)); `ArcResponseHandlerMetadataFunctionalTest` exercises binary producer/consumer classification. Erased `CommandResponseValues` contents remain untyped metadata. | | One-shot queries | Implemented | Static Java or `@JvmStatic` companion methods on `@ReadModel`; class-level `@QueryHttpMethod` defaults with method overrides; non-query companion/static helpers ignored unless explicitly query-annotated, while custom instance methods remain invalid; GET and optional RFC QUERY hosting with case-insensitive client argument names and fail-closed case collisions; generated performers inject interspersed service, `QueryRequest`, and `QueryContext` parameters without exposing them as client input. Omitted Kotlin client defaults execute at invocation, while supplied values, including explicit null, retain presence. | | Concepts | Implemented | Kotlin classes and Java records implementing `ConceptAs` serialize as their underlying scalar or enum value. KSP carries that shape through command, query, model, response, validation, TypeScript, OpenAPI, and Chronicle command-key contracts rather than exposing wrapper objects. | | Polymorphic derived types | Implemented | `@DerivedType` writes `_derivedTypeId`, and code generation records every base-to-derivative mapping it saw — the declared interface plus each base class — on the generated `ArcArtifactModule` as real class references. The Spring Boot starter registers them into a `DerivedTypeRegistry` bean before Jackson reads anything, and `ArcArtifactModuleRegistry.registerDerivedTypes` does the same for explicit in-process registration; a `DerivedTypeRegistrar` bean adds a hierarchy code generation never processed, such as one that arrives only as a dependency binary. Arc .NET scans every loaded assembly at startup instead, so the JVM needs that registrar where .NET needs nothing. Reading fails closed on an identifier the registry cannot resolve, which matches .NET, and also when the discriminator is absent, where .NET returns `null` — an intentional divergence. | | Temporal and UUID proxies | Implemented | Direct and concept-backed `LocalDate`, `LocalTime`, and `UUID` generate `DateOnly`, `TimeOnly`, and `Guid` from `@cratis/fundamentals`; UUID-to-`Guid` is an explicit .NET parity decision. Commands and GET query parameters serialize scalar strings, and returned generated models hydrate as class instances. Core and Spring disable `WRITE_DURATIONS_AS_TIMESTAMPS`, so `Duration` reads and writes ISO-8601 text, generates TypeScript `string`, and is OpenAPI `string`/`duration`; it is not Fundamentals `TimeSpan` because Java and C# wire formats differ. Tested `Period` behavior is limited to Core ISO-8601 round-trip and TypeScript `string` generation. `OffsetTime` generates as textual, untyped `string`; its offset-specific semantics are not hydrated into a class. `LocalDateTime`, `Instant`, `OffsetDateTime`, and `ZonedDateTime` remain JavaScript `Date`. | | Observable queries | Implemented | `ObservableState` is the Java-native counterpart of `MutableStateFlow`: a `Flow.Publisher` that holds a current value, so a Java observable query answers a snapshot `GET` from it exactly as a Kotlin query returning a `StateFlow` does. Without it a Java application could only publish through a plain publisher, which has no current value and is therefore reported as not-ready on every snapshot however promptly it emits. `ObservableStateTest` and `ObservableStateJavaConformanceTest` cover replay, conflation, demand, cancellation and the `asKotlinFlow` unwrap. Primary reactive types are Kotlin `Flow` / `Flow>` and JDK `Flow.Publisher` / `Publisher>`; the optional `arc-rxjava3` artifact also accepts RxJava 3 `Observable`, `ObservableSource`, and `Subject`. Arc .NET uses `IObservable` / `ISubject`; the JDK primary equivalents are `Flow.Publisher` and `MutableStateFlow` / `SubmissionPublisher` respectively, requiring no external dependency. `reactor.core.publisher.Flux` is rejected to preserve the Spring-free `Source` boundary; `org.reactivestreams.Publisher` is rejected because it is superseded by the JDK type. Observable performers use revision ordering, full/delta transfer with the omitted-mode legacy snapshot plus change set, renderers, interceptors, and per-emission guards; keyless or invalid-key collection deltas use Arc .NET-compatible serialized JSON set identity and surface field changes as remove plus add; infrastructure parameters are injected rather than subscription-bound, and omitted Kotlin client defaults remain omitted through every binder. | | Observable HTTP and hubs | Implemented | Bounded GET and enabled RFC QUERY HTTP snapshots that serve a `StateFlow` source’s current value with 200 and report a source without one as not-ready with 202, direct GET SSE/WebSocket, multiplexed SSE/WebSocket hubs, revisions, heartbeats, caller/tenant capture, and terminal authorization behavior. `cratis.arc.observable-queries.allowed-origins` names the browser origins allowed to open a WebSocket; empty keeps Spring’s same-origin default, which a deployment wants and which makes every handshake from a separate dev server fail with a `403` a browser reports only as a socket that never opens. `ArcObservableQueryWebSocketOriginTests` reads the origins off the handler mapping Spring built. Fixed multiplexed routes are connection-anonymous while successful credentials are captured; every subscription is independently authorized and isolated. WebSocket support remains optional by classpath and configuration. | | Query paging and sorting | Implemented | GET reserved parameters, structured QUERY bodies, `QueryPage`, calculated paging metadata, and iterable fallback rendering. `QueryPagingSortingExecutionTest`, `QueryPagingSortingJavaConformanceTest`, and `ArcQueryPagingSortingHostingTests` execute nonempty data through the default pipelines: iterable results sort before zero-based paging and retain the pre-page total even when descriptor flags are false; provider-owned `QueryPage` items/order/totals remain unchanged. Original arrays are normalized for output but bypass iterable sorting/paging/counting. `QueryablePropertyAccessTest`, `QueryablePropertyAccessJavaConformanceTest`, and `ArcQueryPropertyAccessHostingTests` cover public-only accessor selection, singleton/mixed-type rejection, Kotlin declaration visibility across Java inheritance, public bean/record getter semantics, and cancellation/fatal propagation. Private storage is not read as a substitute for a public accessor; inaccessible and absent keys fail without a sorted payload and host exception details remain redacted. This is a JVM visibility safeguard, not a serialization-annotation or per-field authorization policy. Generated convenience methods remain separately capability-driven; no automatic array paging or general .NET backend equality is claimed. | | Query extension points | Implemented | Ordered `QueryRendererFor` and `InterceptReadModel` contracts apply to one-shot and observable results; `GuardObservableQueryEmission` rechecks each observable emission, and a suppressed emission leaves first-delivery status to the next delivered one. Configured renderers match the original value and own data/paging; the automatic iterable renderer is a fallback only when none match. Explicit `QueryableQueryRenderer` stages consume current data and preserve null/non-iterable projections, never restoring original rows. `QueryRendererOwnershipTest`, its Java conformance counterpart, and `ArcQueryRendererOwnershipHostingTests` cover discarded rows, projections, provider totals/no premature source enumeration, explicit ordering, cancellation, and both query transports. Existing application renderers—including identity/logging renderers—must explicitly opt into in-memory processing where wanted; this is a JVM behavioral correction, not a claim of .NET renderer equivalence. | | Query health | Implemented | `QueryHealthTracker` exposes snapshots and a `Flow`; `/.cratis/queries/health` exposes the current connection/subscription health. Intentional divergence: Arc .NET declares the health read model `[AllowAnonymous]`, while the JVM host requires an authenticated caller on both `GET` and `QUERY` whenever authentication handlers are registered, because the snapshot reports connection and subscription identifiers, remote IP addresses, user agents, and user identities. Applications with no authentication handler are unaffected. | | Spring Data JPA | JVM-specific | Repository injection, generated `QueryRequest`/`Pageable`/`Sort` injection, exact `Page` normalization, `DECLARED` contextual command read-model ownership, tenant-certified persistence units, and cold/shared observable `Flow` snapshots with demand-aware Java publishers. JPA notifications are explicit and in-process by default. Imperative command transactions are fixed-store, thread-bound opt-ins. | | Spring Data MongoDB | JVM-specific | Repository injection, generated `QueryRequest`/`Pageable`/`Sort` injection, exact `Page` normalization, `FALLBACK` contextual command read-model ownership, tenant-certified operations, and cold/shared observable `Flow` snapshots backed by reconnecting change streams with demand-aware Java publishers. Change streams require a replica set or sharded cluster. Imperative command transactions are fixed-store, thread-bound opt-ins. | | Command and query-model validation | Implemented | Arc `CommandValidator` and `QueryValidator` contracts, severity thresholds, command validate routes, and stable envelopes apply in the real pipelines. When a Spring `Validator` bean exists, Jakarta validation automatically covers command and client-supplied typed one-shot/observable query-argument graphs; nested `@Valid` collections/maps and cycles retain stable member paths, while infrastructure parameters are neither required nor client-validated. An omitted Kotlin default has no pre-invocation value, so executable violations for that slot are ignored and the default executes at the model boundary. | | Concept validation | Implemented | Reusable `ConceptValidator` rules apply across command and query object/record/collection/map graphs when supplied to the default validation filters. Spring auto-discovers ordered concept-validator beans alongside command/query validators, while replacement default filter beans remain authoritative (`ArcConceptValidationTests`, `ConceptValidationJavaConformanceTest`, and `ArcConceptValidationHostingTests` in `Integrations/SpringBoot`). Imperative bean rules remain server-only, separate from generated Jakarta validation metadata. | | Shared fluent model validation | JVM-specific | `FluentModelValidator` supports thirteen bounded literal rules, Kotlin/ordinary Java constructor authoring, generated module registration and concrete acyclic nested client composition. `ContractTests/FluentValidationContractTest`, `runtime.fluent.contract.ts` and `ArcFluentValidationNativeFunctionalTest` prove generated registration, real JVM/client rejection and indexed library invalidation. Dynamic/async predicates, warning severity, polymorphic/cyclic graphs and shared-model GET/observable transport remain unsupported; legacy annotation credit-card constraints remain server-only. See [shared validation](/arc/backend/kotlin/guides/validation/). | | Member validation opt-out | JVM-specific | `@IgnoreValidation` cuts a logical member edge before getter/extractor access, including direct rules and descendant validation, without pruning authored fluent fingerprints or wire/binding metadata. `Source/IgnoreValidationTest`, `Integrations/SpringBoot/ArcIgnoreValidationHostingTests`, `ContractTests/IgnoreValidationContractTest`, `IgnoreValidationJavaContractTest`, and `runtime.ignore-validation.contract.ts` cover runtime, real Jakarta/HTTP, generated scenarios and real client/server behavior. `ArcFluentValidationNativeFunctionalTest` proves annotation/dependency edits, recovery and fresh-output equivalence. Owner imperative/class/group-sequence callbacks and executable parameter constraints remain active. Runtime bean-only getters are an explicit expansion; active shared graph limits and the fail-closed binary-record boundary remain. Manifest 8 requires producer/consumer regeneration; custom opaque validators require explicit factory integration. See [the bounded contract](/arc/backend/kotlin/reference/validation/#ignore-a-validation-member-edge). | | Validation metadata | Implemented | KSP preserves exactly representable Jakarta constraints, recursive validation, and inherited concept rules in manifests/runtime descriptors. TypeScript validators merge concept and owning-member rules; unrepresentable client rules fail with `ARCKSP0301`. `Phone` and `Url` have client rules; credit-card constraints remain server-only because the pinned client runtime lacks one. JVM annotation coverage is intentionally broader than .NET DataAnnotations (Required, StringLength, MinLength, MaxLength, Range, RegularExpression, EmailAddress, Phone, Url, CreditCard): Hibernate `@Range`, Hibernate `@Length`, and Jakarta `@Digits` are additionally supported on the JVM. Annotation and DSL rules deliberately union rather than following `ValidationRulesExtractor.MergeValidationRules` precedence, because on the JVM both Jakarta and the DSL execute server-side — precedence would make the generated client weaker than the server. See [conjunction, duplication and contradictions](/arc/backend/kotlin/reference/validation/#conjunction-duplication-and-contradictions). | | Authorization | Implemented | Anonymous, policy, role, and scheme metadata works through Arc pipelines and Spring principal capture. Operation-level metadata — `@AllowAnonymous`, `@Authorize` or `@Roles` — replaces the class declaration wholesale, matching Arc .NET’s `AuthorizationEvaluator.IsAuthorized(MethodInfo)`, which resolves the method’s own metadata first and falls back to the declaring type only when the method declares none. An operation therefore both narrows and opens: a `@Authorize` read model can expose one `@AllowAnonymous` query, which is what a login screen needs, and an `@AllowAnonymous` class can close one operation with `@Roles`. `ArcSymbolProcessorAuthorizationPrecedenceCompilationTest` covers both directions through generated metadata and the real `AuthorizationEvaluator`, and the Kotlin sample’s `AuthenticationQueryItem` exercises the widening case over HTTP. `@AllowAnonymous` combined with `@Authorize`/`@Roles` **on the same declaration** is rejected by both implementations. The JVM fails compilation with `ARCKSP0108`. .NET reports an `ARC0019` warning at compile time and, since `v22.21.0`, throws `AmbiguousAuthorizationLevel` during authorization in either evaluator order, including mixed Arc and ASP.NET Core attributes ([Cratis/Arc#2737](https://github.com/Cratis/Arc/pull/2737)). This is evidenced by .NET’s `Source/DotNET/Arc.Core/Authorization/AuthorizationEvaluator.cs` and `Arc.Specs/Authorization/for_AuthorizationEvaluator/when_attributes_contradict`, not a JVM-side equivalence check. For current policy and scheme support across hosts, see the [shared capability matrix](/arc/capabilities/#security-and-identity). JVM `AuthorizationEvaluator` evaluates named policies and compares the caller’s captured scheme; that comparison does not authenticate the request through another scheme. | | Authentication | Implemented | Ordered coroutine `AuthenticationHandler` and Java `AsyncAuthenticationHandler` chains run before Arc endpoints; the first handler that recognizes the request decides, a recognized failure is terminal and cannot be overridden by a later handler, and anonymous is the outcome only when no handler recognized the request. Failures are generic, the chosen principal is propagated, and the identity cache cookie is excluded from credentials. Multiplexed observable transport connections may remain anonymous, but protected query subscriptions still fail through the query authorization pipeline. | | Host correlation | Implemented | A servlet filter on `/*` establishes one correlation identifier per request for every route in the host, Arc-owned or not, matching the placement of the Arc .NET `CorrelationIdMiddleware`. It is ordered ahead of Spring Security’s filter chain and the Arc authentication filter, reuses an inbound `X-Correlation-ID` only when it is a UUID, re-emits the canonical value as the request and response header, exposes it through `ArcCorrelation.of(request)`, and binds it to SLF4J MDC for the servlet thread only. Coroutine-visible correlation still comes from `CommandContext`, `QueryContext`, and the observability starter’s thread-context element. | | Introspection | Implemented | `/.cratis/commands` and `/.cratis/queries` return deterministic generated route, schema, authorization, transport, paging, and documentation metadata and omit infrastructure-owned query parameters. Defaulted client parameters report `hasDefault`, are absent from schema `required`, and expose no expression or invented value. UUID and supported terminal textual `java.time` values (`LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`, `OffsetDateTime`, `ZonedDateTime`, `OffsetTime`, `Duration`, and `Period`) are scalar `string` schemas, including collection elements, rather than recursively described objects. | | Identity | Implemented | Coroutine or Java asynchronous details provider, `/.cratis/me`, schema generation, client-readable identity cookie, and optional-provider startup validation. | | Trusted platform identity headers | JVM-specific | Default-off Spring Security `AuthenticationConverter` and once-per-request chain filter consume the three `x-ms-client-principal` headers only through an explicit trusted-ingress policy (default deny). Canonical ID, name, roles, and provider collision handling follow the inspected `Arc.Core/Identity/MicrosoftIdentityPlatformAuthenticationHandler.cs` at Arc .NET `03200c1c52020e60e5d55c5ed38b5b189a899504`; stricter bounded parsing and partial-submission rejection are deliberate JVM boundaries, not raw handler equivalence. SpringBoot’s `ArcPlatformAuthenticationConverterTests`, `ArcPlatformIdentityAutoConfigurationTests`, `ArcPlatformIdentityHostingTests`, `ArcPlatformIdentityDisabledTests`, `ArcPlatformAuthenticationFilterTests`, and compiled `PlatformIdentityJavaConformanceTest`/`PlatformApplicationChainJavaConformanceTest` prove parsing, optionality/backoff, real `/.cratis/me`, role/scheme/claim authorization, CSRF, existing identity preservation, and once-only execution. Application chains remain authoritative; the opt-in default protects every route. Unsigned base64 never proves sender identity; deployment ingress isolation/authentication remains the application’s responsibility. | | Users and tenants | Implemented | Anonymous `/.cratis/users` and `/.cratis/tenants` aggregate ordered coroutine and Java asynchronous providers, deduplicate by identifier, and return redacted failures. | | Tenancy | Implemented | Fixed/header/query/claim/subdomain/development resolvers, ordered composition, fail-closed required mode, claim-membership checks, request/subscription capture, and application override beans without thread-local state. | | Kotlin ergonomics | JVM-specific | Reified service lookup; result `fold`/`getOrThrow`/`onSuccess`/`validationOrNull`; ordered response factories; and property views for Java-method contracts are Kotlin-only conveniences that preserve the Java ABI. | | Java Core adapters | JVM-specific | Blocking and `CompletionStage` adapters cover command/query filters, authorization filters and policies, validators, command scopes and response handlers, and manual artifacts. `JavaAsyncScope` provides cancellable command/query/authentication/observable/health facades over a caller-owned executor. Caller-thread `BlockingCommandPipeline` execute/validate and `BlockingQueryPipeline` perform facades require explicit per-call or constructor-bound options; Spring supplies backing-off unbound beans. `Source`’s `BlockingPipelineTest`, `BlockingRealPipelineInterruptionTest`, and `BlockingPipelineJavaConformanceTest`, plus `Integrations/SpringBoot`’s `ArcBlockingPipelineTests` and `BlockingPipelineJavaConformanceTest`, cover context/results, validate nonexecution, cancellation/interruption cleanup, migrated bounded-scope/reentry rejection, and Java service injection. Blocking invocations normalize directly observed callback `InterruptedException` (including propagated worker exceptions) to cancellation after cleanup and set the caller interrupt flag; this does not prove physical caller interruption, and swallowed interruption is undetectable. Legacy coroutine and guard-only calls retain their behavior. These are opt-in blocking calls, not another async bridge: no hidden timeout or ambient identity, no arbitrary external-coroutine detection, and no guarantee that cancelling a stage stops external work; see [Java calling limits](/arc/backend/kotlin/get-started/java/#blocking-interruption-and-limits). | | TypeScript one-shot proxies | Implemented | Commands, queries, models, interfaces, derived types, enums, flags, paging hooks, validators, authorization metadata, and Kotlin/Java concepts erased to their underlying client types compile in strict mode against real `@cratis` packages. Query interfaces, descriptors, required arguments, properties, routes, validators, hooks, and imports include only client parameters; service, `QueryRequest`, and `QueryContext` parameters are omitted. Defaulted Kotlin parameters generate optional fields and call arguments and are excluded from `requiredRequestParameters` without copying a server literal. Strict contracts enable `verbatimModuleSyntax`, and generated interfaces use type-only imports where appropriate. Aggregate command metadata selects the single client leaf’s exact type and enumerable shape; handled-only commands use the response-less command contract. Concept validation rules merge with owning command/query rules. The `DateOnly`/`TimeOnly`/`Guid` change is source-breaking for consumers that assigned `Date` or scalar strings. | | TypeScript observable proxies | Implemented | Enumerable and single-result observable classes, React hooks, paging/sorting/change-stream helpers, conditional queries, validation, transport, and HTTP preference metadata omit infrastructure parameters; Kotlin defaults are optional capability metadata without emitted values. For both query transports, named sorting helpers use return-model properties (including represented class-base properties), not request arguments; capability flags remain authoritative. `QuerySortHelperTest` and `ArcProxyMappingRuntimeTest` cover parameterless/parameterized queries, inherited fields, request-field exclusion, collision-safe helper storage and actual sorting actions; `GeneratedTypeScriptProxiesTest` asserts the real KSP-produced Kotlin/Java Spring Data clients select returned `value` rather than request `label`. The pinned .NET model-bound generator derives helper names from parameters, so this is a documented JVM correctness divergence. Provider-specific field support and unrepresented interface inheritance are not inferred. | | .NET-derived proxy differential gate | Implemented | `:GradlePlugin:test` compares sorted paths for all regular output files, then untouched JVM bytes (including headers) against the prepared repository-local expected fixture. Expected-only preparation validates 16 source identities against a literal table and fixture descriptors, reconstructs uppercase SHA-256 headers independently from expected bodies, and leaves three indexes headerless. Queries use declaring-model identities. The [complete preparation inventory](/arc/backend/kotlin/guides/typescript-proxies/#expected-only-differential-preparation) includes expected LF/trailing-whitespace handling, FixtureModel quote/indent preparation, CreateFixtures quote/import/layout/hook preparation and exact-site suppression, and five literal type-only import rewrites for `verbatimModuleSyntax`. Historical namespace/query-name casing and removed timestamps/hashes remain embedded capture-time transformations. `SetCommandValues`, `ClearCommandValues`, and query helper types such as `PerformQuery`, `SetSorting`, `SetPage`, `SetPageSize`, and `ChangeSet` become type-only imports. At `Commands/CreateFixtures.ts`, one additional expected-side correction changes `Command` to `Command`; .NET already calls `super(FixtureModel, true)`, so the scalar generic contradicts its enumerable runtime behavior. Only `Models/Observe.ts` also receives one exactly anchored expected-only formatting change: the zero-space blank line immediately before `ObserveParameters.filter: string;` becomes four spaces; `ObserveOne.ts` is excluded. `ExpectedSortHelperPreparation` additionally replaces the exact helper pairs in `Models/All.ts`, `Models/Search.ts`, and `Models/Observe.ts` with a literal return-field inventory after verifying unique anchors and fixed original block hashes; only All gains SortingActions imports, and the helper constructor no longer exposes its owner as `query`. Request parameters, routes, hooks and capability flags remain unchanged. This is an explicit semantic correction for the JVM’s returned-row helper behavior, not raw .NET output parity. Missing/duplicate correction anchors and misplaced suppressions fail preparation. Actual byte/path mutation tests include a changed body with a valid recomputed hash. `Contracts/Shape.ts` is a class, not interface-emission proof. No normalization transforms JVM output. Capture SDK/tool versions remain unverified. The fixture currently contains no `Guid`, `DateOnly`, or `TimeOnly`, so focused generator and contract tests cover that mapping. This remains a drift gate for the normalized fixture, not an exact raw .NET-output comparison; capture-time fixture preparation is not yet reproducible tooling. | | TypeScript proxy and runtime gates | Implemented | `:ContractTests:typeScriptBuild` installs the locked real `@cratis` packages, verifies deterministic regeneration, and compiles generated proxies in strict mode with `verbatimModuleSyntax` and type-only interface dependencies. The runtime harness has five wired unit tests plus 35 behavioral runtime tests: four ignore-validation tests, four shared-fluent tests and 15 general tests in separate UTC processes, six calendar tests in separate UTC, and six in separate `America/Los_Angeles` processes. Parsed TAP summaries require exact test/pass totals and zero fail, cancelled, skipped, or todo results; Spring spawn errors fail cleanly. The behavioral gate verifies validation, typed and hydrated command responses, safe malformed envelopes, correlation, GET/QUERY, paging/sorting, identity, multiplexed/direct WebSocket, direct SSE, scalar calendar/UUID transport, timezone stability, and `TimeOnly` millisecond truncation; `:ContractTests:check` depends on it. | | Chronicle event transactions | Implemented | Returned events are staged until the final command result succeeds and committed as ordered `appendMany` batches per selected store. Generated command event metadata supplies optional source type, stream type, stream ID, and subject defaults to immediate and staged appends; explicit routed values win and command causation is appended. A collection may mix plain events routed by the captured `CommandContext.commandKey` with explicit `EventForEventSourceId` values; item order and explicit source IDs are preserved and malformed mixtures fail before append. `ChronicleCommandKeyRoutingTests` and its Java conformance counterpart prove stateful key providers are not resolved a second time, including null-key rejection and explicit-route preservation. Separate enrollments from one execution frame reuse one generated command causation link. `ChronicleStagedCausationTests` exercises the pinned real SDK preflight with mocked gRPC transport: different explicit or nested-frame chains remain rejected under its single-chain batch contract, not flattened or split. This does not prove real-kernel acceptance or heterogeneous nested-command lineage support. Failed commands discard staged events. No cross-store distributed transaction is provided. | | Chronicle concurrency | Implemented | `EventsWithConcurrencyScopes` carries ordered routed events and exact per-source Chronicle scopes through Kotlin and Java builders; violations map to machine-readable validation feedback. | | Chronicle read models | Implemented | Chronicle-discovered read models are declared to the command resolver registry, resolved by generated command key and tenant store, and released through a query interceptor before one-shot or observable results leave the pipeline. | | Chronicle command side effects | Implemented | Reactors can explicitly hand registered command values to `ChronicleCommandSideEffectHandler`; nested aggregates run sequentially through the real pipeline and stop on failure. Causing identity is preserved without roles unless the reactor declares exact system roles. | | Chronicle scenarios | Implemented | A `ServiceLoader` extender gives `CommandScenario` an in-memory event log, ordered given history, append assertions, and deterministic constraint/concurrency rejection, with Kotlin extensions and Java static bridges. No Chronicle kernel is started. | | OpenAPI | Implemented | The optional starter generates and caches OpenAPI 3.1 from artifact metadata, including reusable underlying scalar/format/enum schemas for Kotlin and Java concepts, and omits infrastructure-owned query parameters. Kotlin defaulted client parameters are non-required and carry no invented OpenAPI default. UUID, date, time, and duration wire values are scalar strings with `uuid`, `date`, `time`, and `duration` formats. Command result schemas expose only the unique client response leaf from ordered aggregate metadata and omit `response` for handled-only commands. The starter serves `/v3/api-docs` plus `/.cratis/openapi.json`; application beans/routes win. RFC QUERY is omitted because OpenAPI Path Items do not define it. Imperative concept validators are runtime behavior, not OpenAPI keywords. | | Observability | Implemented | The optional starter decorates command execute/validate, one-shot and observable query open/subscription/emission, authentication, and identity-details execution with Micrometer observations. Stable low-cardinality tags exclude tenant, user, command values, and query arguments; correlation uses observation context plus optional OpenTelemetry baggage and SLF4J MDC. | | Source documentation | Implemented | KSP reads Kotlin KDoc and Java Javadoc for commands, query methods, client query parameters, model types and properties, interfaces and properties, and enum declarations, and the manifest carries them as single-line summaries that runtime introspection and generated TypeScript JSDoc render. A member falls back to a Kotlin `@property` or Java `@param` tag; enum members, setters, backing fields, runtime query fields, and barrel files are deliberately undocumented, and OpenAPI keeps conventional descriptions. Only the first paragraph is captured, bounded at 512 Unicode code points. Arc .NET also emits JSDoc from the assembly’s XML documentation file through `Source/DotNET/Tools/ProxyGenerator/XmlDocumentation.cs` and its templates; `ProxyGenerator.Specs/for_XmlDocumentation/when_generating_command_with_documentation.cs` checks command and property comments. This source-backed comparison does not claim identical declaration coverage, paragraph extraction, or length limits. | | KSP diagnostics | Implemented | Stable `ARCKSP` codes cover configuration, commands, queries, proxy shapes, Jakarta validation metadata, enum values, interoperability warnings, and unclassified failures. | | Measured cross-runtime proxy agreement | Partial | `ArcGradlePluginFunctionalTest` generates JVM proxies for Kotlin declarations mirroring the captured .NET fixture and compares them file by file against `differential/captured`, with both generators at `segmentsToSkip` 0. Confirmed from real output on both sides rather than from JVM-side assertion: the generated artifact sets match; routes match exactly; `Address.ts` and `OrderKind.ts` are byte-identical apart from the namespace casing each runtime echoes into the header `Source` field, with identical content hashes, which the test asserts so it cannot silently regress; and `Guid`, `DateOnly`, `TimeOnly`, `DateTimeOffset`/`OffsetDateTime`, enum, and nested-model mappings agree. Cosmetic divergences remain: .NET sorts members alphabetically while the JVM preserves declaration order, .NET emits one import per `@cratis/fundamentals` type while the JVM combines them, and the JVM emits type-only imports for `verbatimModuleSyntax`. Generated paging/sorting remains capability-driven on the JVM: `QueryPage` and exact Spring `Page` returns also advertise paging without adapter parameters. Core’s default renderer already sorts/pages runtime `Iterable` values independently of those flags, but KSP does not advertise that in-memory capability. Arc .NET’s enumerable helper generation is not proof of server behavior: at `v22.14.0` its default renderer handles runtime `IQueryable`, not an arbitrary list or array. The inspected implementation and documentation establish this distinction; the empty capture fixture itself cannot prove pagination. `CapturedProxyContract` now checks exact relative paths (not basenames), capture checksums, fixed header identities, model/enum full bytes after expected-header-only preparation, and independent literal command/query contracts for routes, members, constructors, collection shape, parameters and methods. `CapturedProxyContractTest` mutates those contracts and file inventories to prove mismatches fail, including duplicate basenames and renewed hashes. **This is Partial**: command/query comparison is a bounded token contract with explicit import/order and capability exceptions, not raw byte equality, type-soundness of ignored hook calls, or general equivalence. JVM output bytes are never rewritten. | | Reproducible Arc .NET proxy capture | Partial | `GradlePlugin/src/test/resources/differential/capture/capture.sh` regenerates real Arc .NET proxy output from published NuGet packages at pinned `Cratis.Arc` and `Cratis.Arc.ProxyGenerator` 22.14.0, matching Arc .NET’s `v22.14.0` tag. It never reads, builds, or copies from a sibling checkout. The hardened harness pins SDK 10.0.400, both reflection/runtime frameworks at 10.0.11, the locked NuGet graph and archive/payload hashes; it preserves raw bytes and writes format-2 provenance with input hashes. Output must be a new lifecycle-managed `.ai-work` destination; existing paths and symlinks are refused and publication is atomic without replacement. `test_capture.py` covers corruption, exact normalization, failures and output safety. Two fresh normalized trees were verified equal to each other and the checked-in seven-file comparison fixture; complete raw trees/manifests are intentionally not byte-identical because they retain timestamps. Exactly one normalization is applied, to .NET output only: the generator’s wall-clock `Time:` header field, which the JVM generator does not emit and which otherwise makes the capture differ from itself every run. The newly authored fixture carries `Guid`, `DateOnly`, and `TimeOnly`, confirming from real .NET output rather than assertion that Arc .NET maps them to `Guid`/`DateOnly`/`TimeOnly` from `@cratis/fundamentals`, camel-cases enum members behind a `Number` descriptor, and imports nested models relatively. **This is Partial**: the older hand-prepared `differential/dotnet` drift gate remains separate and unchanged. The seven-file captured contract check above is narrower than a whole-generator or server equivalence proof. `:GradlePlugin:verifyCapturedProxyBaseline` now runs before the ordinary GradlePlugin tests and checks the reviewed seven-file baseline against its input hashes and SDK/runtime/package/tool pins, with mutation tests for stale scripts/locks, byte changes even after renewed snapshot hashes, metadata drift, symlinks and path inventories. This offline consistency gate needs no .NET installation, network, package-cache reads or retained `.ai-work` capture. `verify_baseline.py --candidate` requires a full raw/normalized capture and matching current inputs before emitting a review candidate. The receipt is not an attestation: its evidence digest does not by itself prove tool execution, and coordinated changes still require review. Fresh capture/provenance verification remains explicit, not a hidden network operation in ordinary JVM tests, and Linux capture execution has not been verified. No raw .NET output equivalence follows from this harness. | | External TypeScript package mappings and type overrides | Implemented | `proxies { mapType(...) }` maps a JVM type to a TypeScript type with an optional npm package, and `mapPackage(...)` maps every model type under a JVM package to an npm package it is imported from by simple name. Mappings are consulted ahead of the generator’s built-in type map, so an entry corrects an existing mapping as well as declaring an unknown one; concepts unwrap first, so the underlying type is what a mapping matches, as in Arc .NET’s `TypeExtensions.GetTargetType`. A mapped type is excluded from emission, matching .NET’s `IsFromMappedAssembly` filter, so a local declaration and an external import never collide. The longest matching package wins. Blank components are named in warnings; unsafe or unrepresentable nonblank mappings and import collisions fail before output writes. Unpackaged overrides are limited to `string`, `number`, `boolean`, `object`, and `Date` with actual runtime constructors; manifest interfaces and numeric enums retain `Object`/`Number` descriptors and type-only imports. External classes require compatible Fundamentals field metadata; mappings never change server JSON. External map-value and polymorphic-base/derivative hydration remain unsupported. The standalone CLI takes the same values as repeatable `--type-to-typescript` and `--package-to-npm` options, with the same bounded three-part split. `ArcProxyTypeMappingTest` covers resolution, diagnostics, collision rejection and stale-file transitions. `ArcProxyMappingRuntimeTest` strictly compiles generated clients against the locked packages and executes primitive overrides, nested/list/interface/enum hydration and mapped command/query responses through a local HTTP fixture. `ProxyMappingsJavaConformanceTest` executes the public Gradle task/DSL from Java. Dependency declaration checking uses the repository’s existing `skipLibCheck` setting; generated TypeScript remains checked. The JVM keys package mappings on a JVM package prefix because it has no assembly identity to key on; .NET’s `--exclude-type`, `--exclude-namespace`, and `--namespace-root` options have no equivalent and none is claimed. | | Artifact roots without commands or queries | JVM-specific | `@ExportedType` makes a public top-level concrete class, enum class, interface, or concept an explicit artifact root; unsupported targets fail with `ARCKSP0306`, covered by `ArcSymbolProcessorExportedTypeCompilationTest` and Kotlin/Java negative fixtures. Public concrete source identity providers and typed factory returns now automatically contribute supported details roots, including inherited generic specialization and anonymous providers behind typed factories. `ArcSymbolProcessorIdentityCompilationTest` covers Kotlin, ordinary compiled Java and later rounds; `ArcIdentityDiscoveryFunctionalTest` covers native correction, addition/removal and fresh-output equivalence. `GeneratedIdentityArtifactsTest`, `IdentityArtifactsJavaContractTest` and `GeneratedTypeScriptProxiesTest` cover the module, artifact manifest and generated client. The Kotlin sample generates `SampleIdentityDetails.ts` without an explicit export annotation. Identity-only compilations still emit the module, service entry and empty command/query lists. Erased, wildcard/star, unspecialized, inaccessible or unsupported details boundaries fail with `ARCKSP0307`. Discovery reads declarations, not bodies or runtime beans; dependency-only providers are not scanned wholesale, though source-visible bindings can use dependency generic signatures. Explicit export remains a fallback for supported source DTOs without such a declaration. No .NET output equivalence, assembly scanning, bulk DTO export or `--library-mode` equivalent is claimed. | | Binary compatibility | Implemented | Checked `.api` baselines and `apiCheck` cover published runtime modules, integrations, testing, `arc-ksp`, and `arc-gradle-plugin`; changes require an intentional baseline update. Manifest format 8 adds explicit property validation-ignore metadata after format 7’s optional typed command event metadata, while retaining recursive type shapes, canonical query parameter sources, legacy JVM programmatic constructors, and source documentation. `IgnoreValidationMetadataJavaConformanceTest` calls the earlier property constructors and round-trips the explicit flag; `IgnoreValidationMetadataTest` rejects prior formats and missing flags. Rebuild metadata producers and consumers together. Earlier command constructor descriptors, including Kotlin default-argument bridges, remain available. | | Runtime hardening | Implemented | Bounded coroutine admission/queueing, request bodies, timeouts, scope completion, observable connections/subscriptions/buffers/messages/tombstones, safe error redaction, and 413/429/503 fail-closed responses are implemented. | | Testing | Implemented | Kotlin scenarios and blocking/asynchronous Java bridges exercise real command, query, and observable pipelines with services, filters, validators, authorization, renderers, interceptors, guards, and tenant context. Command-side read models can be pinned to a known state, optionally per command key, through Arc’s own ownership registry rather than a store. A Java-authored temporal contract executes a generated Java command and query through the public `JavaAsyncScope` and real pipelines. Chronicle adds its in-memory scenario extender rather than a separate fake pipeline. | | Roslyn analyzers | JVM-specific | KSP compile-time diagnostics are the JVM substitute; Roslyn does not apply. | | Controllers | Not planned | Arc generates model-bound Spring MVC endpoints. | | Non-Spring hosting | Not planned | Arc targets Spring Boot; the Spring-free compiler/Gradle boundary does not promise another host. | | Static file / SPA fallback hosting | Not planned | Use Spring Boot static-resource serving and application-configured Spring MVC fallback facilities, not Arc hosting APIs. | | `@FromRequest` | Not planned | Existing `QueryRequest`/`QueryContext` injection and Spring binding cover the supported request inputs; no arbitrary request-injection API is promised. | | `@IgnoreAutoRegistration` | JVM-specific | Command/query generation entry points are annotated declarations (`@Command` and `@ReadModel`), so those entry points need no opt-out annotation. Identity details additionally become automatic roots through public concrete source providers and typed factory returns, as covered by `ArcSymbolProcessorIdentityCompilationTest`; this discovery does not register runtime beans. Reachable-type traversal and diagnostic discovery also occur; this does not claim all discovery is annotation-only. `ArcSymbolProcessor.process` defines these entry points. In `CodeGeneration/KSP`, `ArcSymbolProcessorCommandEventMetadataCompilationTest` and `ArcSymbolProcessorQueryDefaultsCompilationTest` cover annotated command/query generation; `ArcSymbolProcessorNegativeCompilationTest` covers the missing-command warning. | | `@GenerateOneOf` | Not planned | Runtime `ArcOneOf` alternatives already exist; generating union types is a separate capability and is not planned. | | Screenplay | Not planned | No JVM equivalent is provided; Screenplay is outside this product’s scope. Existing in-process scenarios are not Screenplay parity. | | Transparent query and Spring Data framework parameters | Implemented | Generated query performers inject exact `QueryRequest`, `QueryContext`, Spring Data Commons `Pageable`, and `Sort` values in declaration order, normalize exact non-null Spring `Page` returns, and emit explicit paging/sorting capability flags. Command handlers resolve exact JPA/Mongo read-model parameters through ownership arbitration and captured tenant context; owned absence maps to Kotlin null, Java `Optional.empty()`, or deterministic `dependencyUnavailable` validation for required parameters without changing ordinary-service and resolver exception behavior. | | Cross-store transactions | Not planned | Chronicle, JPA, and MongoDB scopes cannot form one distributed atomic transaction; applications must design for partial completion and compensation. | | Paired command/query HTTP conformance | Partial | Explicit `:ContractTests:httpConformanceTest` runs nine common cases against three real processes: a newly authored ASP.NET task board from published Arc 22.14.0 (SDK 10.0.400, runtime 10.0.11, locked package/archive checks), plus the existing generated Kotlin and ordinary-Java Spring task boards. `ContractTests/HttpConformance/contract.py` asserts two distinct create IDs and typed responses, GET/QUERY identifier binding, complete enumerable snapshots, validate-route nonexecution, completion response/state, malformed-command 400 validation without mutation/parser details, and 404 status. Success flags/correlation UUIDs and QUERY cache policy are checked. Raw bodies are retained, not rewritten. The gate is opt-in, database-free, loopback-only, bounded and outside ordinary build/check. Harness tests cover mutation detection and process cleanup. This does not prove equivalent validation rules, authentication/authorization rejection, null/default handling, temporal precision, paging requests, streaming, persistence or aggregate lifecycle. Paging counts and framework 404 bodies demonstrably differ and remain outside shared equality assertions; overall parity is still Partial. | | Full Arc .NET parity | Partial | The semantically normalized .NET-derived proxy fixture covers selected generated shapes, but neither that fixture nor the project claims exact raw-output compatibility or every Arc .NET feature, analyzer, persistence seam, or hosting model. | ## Ordered P0 parity backlog 1. **Calendar and UUID proxy fidelity — complete.** `LocalDate`/`LocalTime`/`UUID` now use `DateOnly`/`TimeOnly`/`Guid`, with scalar command and GET transport, generated-model hydration, strict import contracts, and timezone-separated runtime coverage. 2. **Tenant-safe Spring Data command read models — implemented.** Generated Kotlin/Java handlers resolve exact mapped read models through JPA `DECLARED` and MongoDB `FALLBACK` ownership. Tenant routing is certified and fail-closed; compatibility adapters remain fixed-store only. 3. **Nested Chronicle transaction ownership — complete.** Nested structured executions share one explicit root staging area with no savepoints; ignored child failure/cancellation makes the root rollback-only, late joins fail, and the root emits one ordered `appendMany` only after local scopes succeed. The completed ownership slices do not create a distributed transaction. Imperative JPA and MongoDB scopes remain thread-bound opt-ins. MongoDB may commit before JPA fails, and both local stores may commit before Chronicle fails or has an indeterminate external outcome. Applications must continue to design for reconciliation, idempotency, and compensation. ## Remaining temporal client limits Raw `08:09:10.1235567` hydrates through the pinned `TimeOnly` as `08:09:10.123`; because rounding would yield `.124`, the runtime contract proves truncation rather than rounding. Arc accepts and emits `LocalTime` values with up to seven fractional digits for 100 ns compatibility. Deserialization rejects eight or nine fractional digits, and serialization rejects values finer than 100 ns rather than rounding or truncating them. This server binding is distinct from the shared `@cratis/arc` generated-client limitation: explicit RFC QUERY bodies pass `DateOnly` or `TimeOnly` component objects to native `JSON.stringify` because those classes have no `toJSON()`. Prefer GET until upstream serialization uses the typed serializer or `toJSON()`. `Guid` is unaffected because it has `toJSON()`, and the JVM server continues to require scalar date/time strings. JavaScript `Date` also cannot preserve every remaining JVM temporal distinction: mapping `LocalDateTime` invents a zone, while mapping `OffsetDateTime` or `ZonedDateTime` loses original offset or zone identity. ## Server-side temporal precision `LocalTime` is the only temporal type Arc guards at the 100 ns boundary .NET counts in. Every other type goes through `JavaTimeModule` as ISO-8601 text and keeps whatever the JVM holds, which for the date-time types and `Duration` is a full nanosecond — finer than a .NET tick can carry. The table states what the Kotlin mapper does; each row is demonstrated by a test. | JVM type | Arc wire value | Precision the JVM server keeps | Precision the .NET counterpart supports | | --- | --- | --- | --- | | `LocalTime` | `HH:mm:ss` with up to seven fractional digits | Exactly 100 ns. A finer value is refused on write, and eight or nine fractional digits are refused on read. | `TimeOnly` writes the round-trip format, which is seven fractional digits, or 100 ns ticks. | | `LocalDate` | `yyyy-MM-dd` | No sub-day component exists to lose. | `DateOnly` writes the same round-trip date. | | `Instant`, `ZonedDateTime`, `LocalDateTime` | ISO-8601 text | Full nanoseconds, so nine fractional digits survive a round trip. | `DateTime` and `DateTimeOffset` have no Arc converter and use the `System.Text.Json` default, which round-trips at most seven. | | `OffsetDateTime` | ISO-8601 text with an offset | Full nanoseconds are written and read, but a read normalizes the value to UTC, so the original offset does not survive even though the instant does. | Same seven-digit ceiling. | | `Duration` | ISO-8601 text | Full nanoseconds. | `TimeSpan` has no Arc converter and uses the `System.Text.Json` default, which is 100 ns ticks. | | `UUID` | Lowercase dashed text | Exact. A read accepts upper case and normalizes it. | `Guid` has no Arc converter and uses the `System.Text.Json` default dashed form. | A JVM value with more than seven fractional digits is therefore representable on the Arc wire for every type except `LocalTime`, and a .NET peer reading it cannot hold the extra precision. Arc does not detect or reject that today outside `LocalTime`. Applications that must interoperate at exact precision should keep date-time values at or coarser than 100 ns themselves, or model the value as a `LocalTime` where the guard already exists. # TypeScript proxy options compatibility matrix > Classification of every proxy generator option and model-shape boundary against Arc .NET, with pinned reference versions, enforcing guards, and a precise slice definition for anything implementable but not yet present. ## Pinned reference versions | Side | Component | Pinned version | Source | | --- | --- | --- | --- | | JVM | `@cratis/arc` and `@cratis/arc.react` | 22.10.4 | `ContractTests/TypeScript/package.json` | | JVM | `@cratis/fundamentals` | 7.19.3 | `ContractTests/TypeScript/package.json` | | .NET | `Cratis.Arc.ProxyGenerator` | 22.14.0 | `parity.md` capture harness; `GradlePlugin/src/test/resources/differential/capture/capture.sh` | | .NET | .NET SDK | 10.0.400 | Same capture harness | | .NET reference path | `Source/DotNET/Tools/ProxyGenerator/` | `Cratis/Arc` read-only local checkout | `Program.cs`, `TypeExtensions.cs`, `Generator.cs` read directly | Every row below was verified against code read at these versions. The .NET `Program.cs` usage string is quoted in the option table; `TypeExtensions.IsExcluded`, `SetNamespaceRoots`, and `ResolveTargetPath` were read from `TypeExtensions.cs`; and the JVM guards were read from `TypeScriptProxyGenerator.kt` in this repository. No source was assumed to match without reading it. ## Status key | Status | Meaning | | --- | --- | | Supported today | Present and working in the current JVM generator. | | JVM-specific | Supported with a design that differs intentionally from .NET. | | Deliberately unsupported | No equivalent; reason and the enforcing guard are named. Do not remove these guards. | | Implementable with named slice | Not present; a named slice below defines exactly what to build, what would prove it, and what it must not break. | ## `segmentsToSkip`: file layout versus route Arc .NET exposes one positional `segments-to-skip` argument that drops the same number of leading namespace segments from **both** the generated file path and the computed route. The JVM splits this into two independent settings with separate CLI options: | Setting | CLI option | Controls | Forwarded to | | --- | --- | --- | --- | | `endpoints.segmentsToSkip` | `--route-segments-to-skip` | Leading package segments dropped from **routes** | `ApiEndpointOptions.segmentsToSkipForRoute` → `EndpointRouteHelper` | | `proxies.segmentsToSkip` | `--proxy-segments-to-skip` | Leading package segments dropped from generated **file paths** | `ProxyGenerationOptions.segmentsToSkip` → `TypeScriptProxyGenerator.outputDirectory()` | Setting one and expecting the other to follow will not work: they are independent. Both default to `0`. The contract tests fix both at `5` via the `proxyArguments()` block in `GradlePlugin/build.gradle.kts`. The `--namespace-root` slice described later provides a named-root alternative to `segmentsToSkip` for file layout. A project that wants routes and file paths to diverge — for example, a route at `/api/features/orders` but a proxy at `orders/Orders.ts` — uses a different value for each setting. This split has no .NET equivalent. ```kotlin cratisArc { endpoints { segmentsToSkip = 3 // drops 3 leading segments from generated ROUTES } proxies { segmentsToSkip = 3 // drops 3 leading segments from generated FILE PATHS } } ``` ## Option compatibility matrix The Arc .NET `Program.cs` usage line reads verbatim: ```shell Cratis.ProxyGenerator [segments-to-skip] [--library-mode] [--skip-output-deletion] [--skip-command-name-in-route] [--skip-query-name-in-route] [--api-prefix=] [--skip-index-generation] [--use-source-file-as-output-file] [--emit-interfaces] [--use-proxy-file-suffix] [--assembly-to-package==]... [--exclude-type=]... [--exclude-namespace=]... [--namespace-root==]... [--type-to-ts==[=]]... ``` | .NET option | JVM option / DSL | Status | Notes | | --- | --- | --- | --- | | `segments-to-skip` (positional) | `endpoints.segmentsToSkip` + `proxies.segmentsToSkip` | JVM-specific | .NET applies one value to both route and file path. The JVM requires two separate values. See the previous section. | | `--api-prefix=` | `endpoints.routePrefix` / `--route-prefix` | Supported today | Both default to `api`. | | `--skip-command-name-in-route` | `endpoints.includeCommandNames = false` | Supported today | Polarity is inverted; semantics are identical. | | `--skip-query-name-in-route` | `endpoints.includeQueryNames = false` | Supported today | Polarity is inverted; semantics are identical. | | `--skip-output-deletion` | `proxies.removeStaleGeneratedFiles = false` | Supported today | Polarity is inverted; semantics are identical. | | `--use-proxy-file-suffix` | `proxies.useProxyFileSuffix = true` / `--use-proxy-file-suffix true` | Supported today | Off by default. The .NET CLI takes a bare flag; the JVM CLI requires an explicit `true` or `false`. Generated type files become `Name.proxy.ts`; relative imports and barrel exports use `.proxy`, but `index.ts`, exported symbols, routes, and mapped external modules do not change. | | `--type-to-ts==[=]` | `proxies.mapType(...)` / `--type-to-typescript` | Supported today | Identical semantics: consulted ahead of the built-in type map; bounded three-part `=` split; warns and skips unusable entries. `ProxyTypeMappings.parseTypeMappings()` mirrors the .NET bounded-split comment exactly. | | `--assembly-to-package==` | `proxies.mapPackage(jvmPackage, npmPackage)` / `--package-to-npm` | JVM-specific | .NET keys on the assembly name (e.g., `MyLib`). The JVM has no assembly concept: it keys on a fully qualified JVM package prefix (e.g., `com.example.shared`), and the longest matching prefix wins. `parity.md` records this divergence in the “External TypeScript package mappings and type overrides” row. | | `--exclude-type=` | None | Deliberately unsupported | The JVM generates only types that appear in the KSP-produced manifest. A manifest type referenced from a command, query, or model cannot be excluded without also providing a TypeScript replacement: `resolveType()` in `TypeScriptProxyGenerator.kt` throws `GradleException("Unsupported Arc proxy type '...' in '...'.")` for any unresolvable reference. Use `mapType()` to redirect a type to an external npm import instead. | | `--exclude-namespace=` | None | Deliberately unsupported | Same reasoning as `--exclude-type`. Use `mapPackage()` to redirect an entire JVM package to an external npm package. The same `resolveType()` guard applies for unresolvable types. | | `--namespace-root==` | None | Implementable with named slice **proxy-namespace-roots** | See the named-slice section below. | | `--skip-index-generation` | None | Deliberately unsupported | `TypeScriptProxyGenerator.updateIndexFiles()` is called unconditionally from `generate()`. Barrel index files are part of the consumer-facing import contract; suppressing them breaks directory-level imports. There is no flag to skip this behavior and no plan to add one. | | `--library-mode` | None | Deliberately unsupported | Arc .NET emits proxies for every public type in the loaded assembly under `--library-mode`. The JVM model is annotation-driven: only types reachable from `@Command`, `@ReadModel`, and `@ExportedType`-annotated sources enter the KSP manifest. Bulk emission without annotation is outside the JVM model. | | `--emit-interfaces` | None | Deliberately unsupported | The JVM emits model classes with `@field` decorators from `@cratis/fundamentals` 7.19.3 because the client runtime uses them for JSON hydration. Arc .NET’s `--emit-interfaces` strips `@field` and the runtime dependency for packages that never deserialize. The JVM does not expose this mode because it would break the client hydration contract. | | `--use-source-file-as-output-file` | None | Deliberately unsupported | The JVM emits one `.ts` file per type. Grouping by source file is a C# idiom (a single `.cs` file may define multiple types); the JVM has no analogous concept and no grouping mechanism. | ## Generated proxy file suffix Enable the suffix when hand-written TypeScript shares a folder with generated files: ```kotlin cratisArc { proxies { outputDirectory.set(layout.projectDirectory.dir("frontend/src/generated")) useProxyFileSuffix.set(true) } } ``` For the standalone JVM CLI, pass `--use-proxy-file-suffix true` (or `false` to opt out); unlike .NET’s bare `--use-proxy-file-suffix` flag, the JVM CLI requires a boolean value. Commands, queries, models, interfaces, and enums become `Name.proxy.ts`; `index.ts` still exports the same public type names through paths such as `export * from './Name.proxy';`. Direct imports of generated files must update their paths. External npm imports specified by `mapType`/`mapPackage` retain their original module paths. With `removeStaleGeneratedFiles` enabled (the default), switching either direction removes only stale generated files and their barrel exports; hand-written files and unrelated barrel entries remain. With cleanup disabled, old generated files and barrel paths remain alongside new ones; the barrel can then export the same TypeScript symbol twice. Clean up the old generated files and exports before using the switched barrel. ## Model-shape compatibility matrix | Shape | Status | Current JVM contract and enforcing guard | | --- | --- | --- | | Class-hierarchy base type (`extends`) | Supported today | `TypeDescriptor.baseTypeName` carries the qualified base class name. `renderType()` emits `export class Foo extends Base { … }`. `resolveBaseType()` requires the resolved base to be an `ArtifactKind.TYPE`; if not, it throws `GradleException("Arc base type '...' must be a generated model type.")`. | | External (mapped) type as base class | Deliberately unsupported | `resolveBaseType()` in `TypeScriptProxyGenerator.kt` throws for any resolved base that is not a generated `ArtifactKind.TYPE`. Emitting `extends ExternalType` would require `@field` constructor metadata the generator cannot produce for types it does not own. Do not remove this guard. | | Polymorphic derivative hydration (manifest-registered types) | Supported today | `@DerivedType` writes a discriminator; KSP records the derivative mapping in the manifest; `renderType()` emits `@field(Constructor, isEnumerable, [Derivatives…])`; `DerivedTypeRegistry` handles runtime hydration. See `parity.md` “Polymorphic derived types” row for the JVM-specific `DerivedTypeRegistrar` requirement. | | External (mapped) type as derivative | Deliberately unsupported | `resolveDerivatives()` in `TypeScriptProxyGenerator.kt` throws `GradleException("Arc derivative '...' must be a generated concrete model type.")` for any type that is not an `ArtifactKind.TYPE`. Do not remove this guard. | | Map with nonnullable String keys and safe primitive scalar values | Supported today | `resolveMapShape()` accepts `MAP_STRING_TYPE_NAMES` (`kotlin.String`, `java.lang.String`, `String`) keys and `MAP_SAFE_PRIMITIVE_TYPE_NAMES` value leaves (boolean, byte, char, int, short, String and their JVM/Kotlin variants). A runtime prototype-pollution guard is injected as `sanitizeArcStringMap()`. | | Map with non-String keys | Deliberately unsupported | `resolveMapShape()` throws `GradleException("Map property '...' entry path '...key' must use nonnullable String keys.")` when `keyCodec != MapKeyCodec.STRING` or the key type is outside `MAP_STRING_TYPE_NAMES`. Arc .NET emits `ValueMap` from `@cratis/fundamentals` for non-string-key maps; the pinned `@cratis/fundamentals` 7.19.3 client exports `ValueMap`, but the JVM manifest carries no non-string key codec so the generator cannot produce correct `ValueMap` output. Do not remove this guard. | | Map with typed model or interface values | Deliberately unsupported | `resolveMapShape()` throws `GradleException("Map property '...' entry path '...' has unsupported value leaf '...'.")` for value types outside `MAP_SAFE_PRIMITIVE_TYPE_NAMES`. Typed model values would require nested `@field`/hydration metadata on the map value path; the current map shape does not emit these. | | Map with nullable entry paths | Deliberately unsupported | `resolveMapShape()` throws `GradleException("Map property '...' entry path '...' cannot be nullable.")` for any nullable path beyond the map root. | | Sequence of sequences (nested arrays as map value) | Supported today | `TypeShapeKind.SEQUENCE` nested inside another `SEQUENCE` in `resolveMapShape()` is handled: the element shape is resolved recursively and the TypeScript type becomes `element[]`. | | Map in a shared-validator context | Deliberately unsupported | `appendSharedEdge()` throws `GradleException("[ARCVALIDATION_GRAPH] Shared model maps are unsupported.")`. Client-side shared validation does not traverse map entries. | | Interface declared properties | Supported today | `InterfaceDescriptor.properties` generates a TypeScript `export interface` block via `renderInterface()`. Properties are emitted in declaration order with optional markers. | | Interface inheritance (`extends` between interfaces) | Deliberately unsupported | `InterfaceDescriptor` carries no base-interface field. Manifest format 8 records no interface inheritance chain. Emitted TypeScript interfaces have no `extends` clause. Sort helpers for interface return types include only the interface’s own declared properties; `returnedSortProperties()` contains the comment `// Interface inheritance is not represented by the manifest; do not invent missing members.` A format bump and renderer change would both be required before this could be supported. | | Result-field sort helpers (class return type, including class-base chain) | Supported today | `returnedSortProperties()` walks the `TypeDescriptor.baseTypeName` chain of the return type, collecting all declared properties at every level into a sorted set. `${ClassName}SortBy` and `${ClassName}SortByWithoutQuery` helpers are generated for every named field. Cyclic inheritance is detected and throws `GradleException`. | | Result-field sort helpers (interface return type) | Supported today, with boundary | When the return type resolves to an `InterfaceDescriptor`, `returnedSortProperties()` reads only its declared properties. It does not walk interface inheritance because the manifest records no inheritance chain. Sort keys reflect only the declared members of that specific interface. | | Result-field sort helpers (external or dependency-only return type) | Deliberately unsupported | When the return type resolves to an external (mapped) type, `returnedSortProperties()` finds no entry in `artifacts.types` or `artifacts.interfaces` and falls through without producing sort helpers. No exception is thrown; the `supportsSorting` capability flag remains authoritative, but the generated helper classes are empty. | | Types from dependency libraries built with Arc KSP | Supported today | `ArcManifestDiscovery.discover()` scans `META-INF/cratis/arc/*.json` from every directory and jar on the configured manifest classpath, including dependency jars. Types from libraries that ran Arc KSP and produced a manifest are fully discoverable. | | Types from dependency libraries not built with Arc KSP | Deliberately unsupported | Libraries that were not built with Arc KSP produce no `META-INF/cratis/arc/*.json` manifest entry. `ArcManifestDiscovery` cannot discover their types and the generator cannot produce proxies for them. Use `mapPackage()` or `mapType()` to import such types from a published npm package. The `arc.responseHandlerMetadata` dependency handler index handles response-handler classification from binaries separately; it is not a model-type manifest. | ## Named slice: proxy-namespace-roots Arc .NET’s `--namespace-root==` (implemented in `TypeExtensions.ResolveTargetPath()`) strips a namespace prefix from the output path and places the remainder under a named folder. When multiple roots are configured the longest matching prefix wins. This is a file-layout control only and does not affect routes. **What the JVM slice would do.** 1. Add `namespaceRoots: List>` to `ProxyGenerationOptions` and a corresponding `proxies.mapNamespaceRoot(jvmPackage: String, outputFolder: String)` method on `ArcProxyOptions`. 2. Update `outputDirectory(location: List)` in `TypeScriptProxyGenerator.kt` to: join the `location` list back into a dot-separated package string, attempt to match the longest configured root whose value is a strict package-boundary prefix of that string, build the output path from `outputFolder + remainder`, and fall back to the existing `segmentsToSkip` drop when no root matches. 3. Add `--namespace-root` as a repeatable `` option to `GenerateArcProxiesCli.kt`. 4. Apply the existing `validateTarget()` safe-path and root-escape guards unchanged to every segment produced by the new logic; the path-escape check (`!destination.startsWith(root)`) is unconditional. **What would prove it.** * At least two generator unit tests: one asserting that a type whose package matches a root lands under the mapped folder; one asserting that a type outside every root still uses `segmentsToSkip`. * A test asserting that a root whose folder component would produce an unsafe segment is rejected before output writes. * `GradlePlugin:verifyContractTestProxyDeterminism` stays green (root matching must produce a consistent sort order across consecutive runs). * No dangling imports, path escapes, or `index.ts` collisions in the output tree. **What it must not break.** * The `segmentsToSkip` fallback for types outside every configured root. * The `validateTarget()` path-safety and escape guards. * The `sanitizeArcStringMap` guard and all existing map-property protections. * The proxy differential gate (`:GradlePlugin:test`) and strict TypeScript compile gate (`:ContractTests:typeScriptBuild`). ## Relationship to parity.md The “External TypeScript package mappings and type overrides” row in `parity.md` already records: “The JVM keys package mappings on a JVM package prefix because it has no assembly identity to key on; .NET’s `--exclude-type`, `--exclude-namespace`, and `--namespace-root` options have no equivalent and none is claimed.” This matrix extends that record with per-option rationale, named enforcing guards, and the `proxy-namespace-roots` slice definition. The suffix behavior is covered by `ProxyFileSuffixTest`, `ProxyMappingsJavaConformanceTest`, and `ArcGradlePluginFunctionalTest` in `GradlePlugin`; `:GradlePlugin:verifySuffixedContractTestProxies` strictly type-checks suffixed output from the Kotlin and Java fixture manifest. No parity status changes follow from this document alone. A status upgrade requires a test, contract test, or runnable sample that proves the behavior, named in the same change. Do not interpret this matrix as a claim that all TypeScript mappings now match Arc .NET. # Shared fluent validation contract > Check the supported rules, declaration restrictions, registration resources and failure stages for shared Kotlin and Java validation. ## Declaration and member contract Follow the [validation walkthrough](/arc/backend/kotlin/guides/validation/) for setup, complete sample declarations, command invocation, client feedback and QUERY requests. A shared declaration is a public final top-level `FluentModelValidator` with one concrete model type and a public no-argument constructor. Kotlin permits one `init` block; Java requires `super(Model.class)` followed by direct fluent chains. The compiler parses the complete restricted body; it does not execute application constructors. Runtime registration may construct validators and checks their frozen rules against compiler metadata. * Use direct public readable member names matching `[A-Za-z_$][A-Za-z0-9_$]*`, not paths or indexes. * Declare rules alongside the model’s source. New selectors over binary-only or inherited members fail closed; indexed dependency rules retain their producer’s proof. * Accessors must preserve the wire value: a default getter, Kotlin `get() = field` or Java `return this.name` is supported. `field.trim()` or a transforming record accessor is not. A backing field alone does not prove identity. * Do not add fields, initializers outside the allowed body, helper methods, extra constructors, local variables, branches, aliases, extension calls, lambdas, interpolated strings or computed arguments. Do not shadow Kotlin’s standard `Model::class.java` mapping. * Reading `rules`, registering or evaluating freezes the declaration. Escaped builders cannot mutate it afterward. Direct evaluation requires the exact model runtime class. ## Ignore a validation member edge `io.cratis.arc.validation.IgnoreValidation` is a runtime-retained logical-member annotation. Kotlin supports `@IgnoreValidation` on a property, `@field:IgnoreValidation`, and `@get:IgnoreValidation`. Java supports instance fields, bean getters, and record components through their propagated field/accessor annotations. Compiled record private-field annotations, including a header annotation with an explicit accessor, are read from the exact compiler classpath without loading application classes. The Arc plugin supplies `arc.validationClasspath` automatically and tracks it as a nonincremental classpath input, so private annotation edits invalidate unchanged consumers even when public ABI and authored fluent metadata do not change. Manual KSP wiring must supply absolute file URIs for the compilation’s directories/JARs joined by `|`, plus the same nonincremental classpath task input; the Kotlin sample and ContractTests build files show the main/test-fixtures forms. This is an input-location option, not another generated metadata resource. Missing evidence, conflicting definitions or unreadable records fail with `ARCKSP0311`; a visible accessor whole-edge opt-out is sufficient without private annotations. Do not add an ignore annotation merely to silence missing classpath evidence for an active edge. Unannotated binary records remain active when their field metadata can be inspected. There is no class, type-use, constructor-parameter, setter, static member, or executable query-parameter opt-out. KSP reports unsupported or ambiguous placements with `ARCKSP0311`; language-illegal annotation targets are rejected by the Kotlin/Java compiler itself. The annotation cuts **one member edge before access**: direct fluent, concept and Jakarta member constraints, container-element constraints, and descendant validation through that edge do not run. A throwing ignored getter and an ignored container’s value extractor are not invoked. An active sibling/alias remains eligible; validating the child separately as a root still validates it. Genuine property overrides inherit the policy; hidden fields are ambiguous, not overrides. This is not an access prohibition. Owner-level imperative `ModelValidator` callbacks, Jakarta class constraints and group-sequence providers still run and may read the member. Their feedback is not post-filtered, even when it names an ignored member. Serialization, command keys, authorization, nullability/binding requirements, and executable parameter constraints remain unchanged. It does not mean `@JsonIgnore`. Existing concept-only exclusions keep their narrower behavior. Runtime traversal now also visits **bean-only Java getters**, including on models without this annotation. Public reflected fields retain precedence on the same logical edge. This is an explicit runtime graph expansion, not new support for computed/bean-only shared wire models: KSP still requires a representable property and rejects an annotated unbacked generated-model getter. Source-proved fluent declaration restrictions and the supported graph limits below still apply. ### Jakarta factory ownership With Boot’s default `LocalValidatorFactoryBean`, Arc obtains a dedicated validator from that exact factory’s `usingContext()`, composing `IgnoreValidationTraversableResolver` with `factory.getTraversableResolver()`. It does not replace the application Validator bean or create, close, or otherwise own its factory. An application-configured resolver remains the delegate. Automatic adaptation is limited to the ordinary `LocalValidatorFactoryBean` class: subclasses and custom Validator/ValidatorFactory facades may override validation behavior, so their owner must explicitly choose the factory adapter. `Configuration.getDefaultTraversableResolver()` is only the **provider default**, not the current application resolver; it is not used for composition. This factory-context adaptation needs no Boot customizer module on Arc’s production classpath and remains optional without Jakarta/Boot validation classes. No fallback factory is installed. For an application-owned factory, explicitly supply the Java-friendly adapter as the selected Validator bean: ```java @Bean Validator arcAwareValidator(ValidatorFactory applicationValidatorFactory) { return IgnoreValidationValidator.fromFactory(applicationValidatorFactory); } ``` Imports are `jakarta.validation.Validator`, `jakarta.validation.ValidatorFactory`, `io.cratis.arc.validation.IgnoreValidationValidator`, and Spring’s `Bean`. The application must keep its configured factory alive. This recipe does not turn an arbitrary existing bare Validator into a factory: custom per-validator context/decorator behavior must be integrated by its owner, not silently replaced using some unrelated injected factory. Arc uses full-object validation and executable cascades. Direct `validateProperty`, `validateValue` and unwrapped provider-specific APIs retain provider semantics; they are not covered by this pre-access graph guarantee. An opaque Validator remains usable for unaffected statically provable graphs. Discoverable ignored command/query inputs fail startup with the selected validator class, member and adapter remedy; late imperative inputs are checked before validation. Unprovable dynamic cascades also require the adapter, before reading the parent getter. Named Arc command/query filter beans still back off for application replacements. Custom filters own their own validation policy. ### Manifest format 8 migration Every command/model/interface property now carries a required JSON boolean `ignoreValidation`. `true` requires empty **effective** `validationRules` and `validateRecursively: false`; those two fields alone do not suppress automatic shared traversal. The property, canonical shape, command key and documentation summary remain present. The Gradle reader rejects missing/nonboolean flags, inconsistent ignored rules/recursion, and every manifest version other than **8**. Rebuild all producer/dependency artifacts and regenerate consumers together; format 7 is not accepted. The public `PropertyDescriptor` has an explicit eight-argument canonical constructor ending in `summary, ignoreValidation`, and a `getIgnoreValidation()` Java getter. All earlier JVM constructor signatures remain available and default to `false`. Equality/hash code include the flag. The manual KSP JSON writer, runtime JSON, generated factories and descriptor merge preserve it; duplicate producers must agree about the flag instead of unioning rules back into an ignored edge. The DSL declaration resource, frozen `rules`, compiler expectations and runtime registration fingerprint retain **all authored rules**. Ignoring a member changes effective evaluation and client composition, not declaration validity or agreement. OpenAPI retains the property type and required binding slot; it adds no validation constraints for the ignored edge. This does not introduce general Jakarta-constraint-to-OpenAPI projection. ## Rule signatures, types and default messages All rules produce error severity. `withMessage(value: String)` (Java `String`) accepts a literal message and changes only the immediately preceding rule. Only the first `{PropertyName}` is replaced with the selected member name, not the qualified nested path. No localization or dynamic message expression is implied. In this table, **length-capable** means `String`, a supported collection, or array; **numeric** means Kotlin `Byte`, `Short`, `Int`, `Long`, `Double`, or JVM `BigInteger`/`BigDecimal`, including Java primitive/boxed equivalents where applicable. Float members reject. A runtime-supported type must also satisfy KSP’s model/wire-shape contract; the table does not widen supported arrays or graphs. `n`, `min` and `max` in messages below stand for rendered argument values. | Signature | Member type and acceptance | Default message | | --- | --- | --- | | `notNull()` | Any supported readable member; rejects null (and client undefined) | `'{PropertyName}' must not be empty.` | | `notEmpty()` | Length-capable; rejects null, empty containers and ECMAScript-trimmed empty strings | `'{PropertyName}' must not be empty.` | | `minLength(min: Int)` | Length-capable; inclusive lower bound; null passes | `'{PropertyName}' must be at least min characters.` | | `maxLength(max: Int)` | Length-capable; inclusive upper bound; null passes | `'{PropertyName}' must be at most max characters.` | | `length(min: Int, max: Int)` | Length-capable; inclusive bounds; null passes | `'{PropertyName}' must be between min and max characters.` | | `emailAddress()` | `String`; null and empty pass; otherwise bounded email check below | `'{PropertyName}' is not a valid email address.` | | `phone()` | `String`; null and empty pass; otherwise ASCII digits, ECMAScript whitespace and `()+-` only | `'{PropertyName}' is not a valid phone number.` | | `url()` | `String`; null and empty pass; otherwise HTTP(S) prefix check below | `'{PropertyName}' is not a valid URL.` | | `matches(pattern: String)` | `String`; null and empty pass; searches with the portable regex subset below | `'{PropertyName}' is not in the correct format.` | | `greaterThan(n: Number)` | Numeric; exclusive lower bound; null passes | `'{PropertyName}' must be greater than n.` | | `greaterThanOrEqual(n: Number)` | Numeric; inclusive lower bound; null passes | `'{PropertyName}' must be greater than or equal to n.` | | `lessThan(n: Number)` | Numeric; exclusive upper bound; null passes | `'{PropertyName}' must be less than n.` | | `lessThanOrEqual(n: Number)` | Numeric; inclusive upper bound; null passes | `'{PropertyName}' must be less than or equal to n.` | Java length parameters are `int`; numeric parameters are `Number`. Length bounds must be literal nonnegative Int values, from 0 through 2147483647, not Long or Double bounds. Strings count UTF-16 code units, so one supplementary character counts as two; collections/arrays count elements. The email check requires exactly one `@`, at least one character before it, no ECMAScript whitespace, and a dot with at least one character between `@` and the dot and at least one after the dot. It is not Jakarta email validation. Phone allows whitespace-only strings; add `notEmpty` if needed. URL requires a case-insensitive `http://` or `https://` prefix with at least one following character that is not LF, CR, U+2028 or U+2029. It does not parse a URI or validate the rest of the string: `http://a` followed by a newline passes, while a newline immediately after the prefix fails. ECMAScript whitespace here is U+0009, U+000B, U+000C, U+0020, U+00A0, U+1680, U+2000–U+200A, U+202F, U+205F, U+3000, U+FEFF, LF, CR, U+2028 and U+2029. It is not Kotlin `isBlank` or Java `\s`; U+0085 is not in this set. The sample’s vectors exercise these distinctions. ### Supported Jakarta and Hibernate annotations When a supported Jakarta or Hibernate Validator annotation appears on a model property, KSP reads it and emits the equivalent client rule into the manifest alongside any DSL rules. Annotations not in this table are ignored; `groups` and `payload` metadata cause the property to reject with `ARCKSP0301`. | Annotation | Member type | Client rule(s) emitted | | --- | --- | --- | | `@Valid` | Any model | Marks `validateRecursively`; no DSL rule emitted | | `@NotNull` | Any | `notNull()` | | `@NotBlank`, `@NotEmpty` | Length-capable | `notEmpty()` | | `@Size(min, max)` | Length-capable | `minLength(min)` when min > 0 only; `maxLength(max)` when max < `Int.MAX_VALUE` only; `length(min, max)` when both bounds are non-default | | `@Min(value)` | Numeric | `greaterThanOrEqual(value)` | | `@Max(value)` | Numeric | `lessThanOrEqual(value)` | | `@DecimalMin(value, inclusive)` | Numeric | `greaterThanOrEqual(value)` when inclusive (default); `greaterThan(value)` otherwise | | `@DecimalMax(value, inclusive)` | Numeric | `lessThanOrEqual(value)` when inclusive (default); `lessThan(value)` otherwise | | `@Positive` | Numeric | `greaterThan(0)` | | `@PositiveOrZero` | Numeric | `greaterThanOrEqual(0)` | | `@Negative` | Numeric | `lessThan(0)` | | `@NegativeOrZero` | Numeric | `lessThanOrEqual(0)` | | `@Pattern(regexp)` | String | `matches(regexp)`; see [portable patterns](#portable-patterns) | | `@Email` | String | `emailAddress()`; adds `matches(regexp)` when a non-default regexp is supplied | | `@Range(min, max)` | Numeric | `greaterThanOrEqual(min)` always (Hibernate’s default lower bound 0 is a real constraint even when only `max` is written); `lessThanOrEqual(max)` only when max ≠ `Long.MAX_VALUE` | | `@Length(min, max)` | Length-capable | Same three-branch mapping as `@Size`: `minLength`, `maxLength`, or `length` | | `@Digits(integer, fraction)` | Numeric or String | `matches("^[+-]?\\d{1,integer}$")` when fraction=0; `matches("^[+-]?\\d{1,integer}(\\.\\d{1,fraction})?$")` otherwise | | Arc `@Phone` | String | `phone()` | | Arc `@Url`, Hibernate `@URL` | String | `url()` | | Arc `@CreditCard`, Hibernate `@CreditCardNumber` | String | `creditCard()` — server metadata only; proxy generation rejects because the pinned TypeScript client has no credit-card validator | Jakarta constraint package is `jakarta.validation.constraints`; Hibernate Validator constraints are `org.hibernate.validator.constraints`. Annotation and DSL rules conjoin into a union; see [conjunction, duplication and contradictions](#conjunction-duplication-and-contradictions) for the exact deduplication and contradiction-checking semantics. ### Numeric bounds and values Although the API accepts `Number`, shared source declarations accept **numeric literals**, not `BigDecimal("2")`, `new BigInteger("2")`, constants, arithmetic or factory calls. Kotlin’s restricted syntax accepts decimal integer, fractional and exponent forms, an optional leading minus, and `L`/`f`/`F` suffixes; it does not accept hexadecimal or underscore-separated numbers. Java uses ordinary parser-proved numeric literals. A Float literal bound may normalize safely; that does not make a Float **member** safe. Prefer integer or Double literals for clarity. Both normalized bounds and evaluated values must be finite, within **±9007199254740991**, not nonzero JavaScript subnormals (absolute value below `Double.MIN_NORMAL`), and decimal-round-trippable through a JavaScript number. BigDecimal and BigInteger **members** are permitted only within this bounded domain: this does not promise arbitrary-precision client equality. A direct JVM validator call throws `IllegalArgumentException` for an out-of-domain numeric value, for example `9007199254740992L` or `BigDecimal("0.100000000000000001")`, rather than returning a normal comparison violation. Inside the existing model-validation pipeline, the failed declaration becomes sanitized error feedback: message `The value could not be validated.`, reason `validatorFailed`, and the **model path**, such as `input`, not the numeric member path `input.greater`. A root model has an empty members list. Generated validators guard the client number domain and return the corresponding failure. This is distinct from a valid-domain number that simply violates `greaterThan(2)`, which reports its ordinary rule message and member. Cancellation and fatal errors retain the pipeline’s existing propagation behavior. ### Portable patterns `matches` searches; use anchors for a whole-string condition. The allowed subset is printable ASCII literals, nonempty positive ASCII character classes, grouping, alternation, quantifiers, anchors, and `\d`, `\w`, `\s`. `\s` uses the ECMAScript set above. `$` enforces the end of input, including rejection of a trailing newline for an anchored pattern. Escape literal closing brackets: regex `^[\]]+$`, written as `"^[\\]]+$"` in Kotlin or Java, accepts `]` and `]]` but rejects `a`, `]a` and `]` followed by a newline. Ambiguous `[]]`, empty classes and unescaped closing brackets reject. Also unsupported: dot outside a class, negated or nested classes, lookarounds, flags, backreferences, Unicode/property escapes, intersections and possessive quantifiers. This is a portability boundary, not a regex execution-time guarantee. Existing annotation regex screening is unchanged. ### Conjunction, duplication and contradictions Rules conjoin; there is no last-declaration override. Exact rule/argument/message duplicates within one declaration run once after canonicalization; distinct messages and distinct validator classes remain separate feedback sources. Annotation and DSL metadata form a union of constraints, not a choice of validation engine. Jakarta still evaluates its own constraints on the server; shared rules do not replay them or deduplicate all Jakarta feedback. Within a declaration, length chains must have a nonempty intersection: `minLength(3)` with `maxLength(2)`, or `notEmpty()` with `maxLength(0)`, rejects. Numeric lower/upper bounds reject when reversed or when equal with an exclusive endpoint. Inclusive equal endpoints are allowed. This bounded check is not a solver for contradictions across arbitrary patterns, annotations or separate validator classes. Generated descriptor merging also requires structurally compatible models; it unions validation metadata instead of silently choosing one producer’s definition. New shared `creditCard()` rejects because pinned `@cratis/arc` 22.10.4 has no such rule. Existing Jakarta `@CreditCard` and Hibernate `@CreditCardNumber` metadata and server enforcement remain unchanged and server-only. ## Supported graph and presence boundaries The shared graph must be concrete, final and acyclic. Generated command/query validators compose active nested model rules, including supported collection siblings and indexed paths, without requiring Jakarta `@Valid`. Annotation-only client behavior is unchanged; `validateRecursively` metadata alone does not promise general recursive Jakarta client execution. Inherited/polymorphic graphs, cycles, erased inline-value members, scalar/concept shared roots, opaque `Any`/`Object` input edges, serialization-ignored/computed shared edges and external mappings of active shared models reject. Maps containing models remain outside the existing wire-shape contract. Existing Java object-array property restrictions still apply; supported Kotlin arrays and Java lists are separate contracts. An explicit validation-ignored edge can be skipped in validation graph analysis, but its serialization shape must still be representable. This does not claim support for all polymorphic, inherited or cyclic shared graphs. Use server-only validators for unsupported graphs. Identity tracking runs once per node within a command or a supplied query-argument root. Separate query arguments have independent tracking. JSON does not preserve aliases: serializing one aliased object twice creates two server nodes, so feedback paths may differ from pre-serialization in-memory validation. Arbitrary JavaScript getters/prototypes, custom Jackson serialization and mutated values outside the declared wire shape are not a cross-runtime equivalence promise. Shared query model arguments require request-response RFC QUERY preference and host support. Omitted Kotlin defaults stay absent before invocation; explicit null is supplied and requires a nullable parameter. Neither creates a model node to validate before invocation. Supplied objects are validated, not returned data. See the [complete QUERY example](/arc/backend/kotlin/guides/validation/#validate-a-supplied-query-model). ## Registration and library packaging The Arc Gradle plugin supplies a verified compile/runtime dependency index automatically. Generated artifact modules contribute shared validators to Spring and published in-process scenarios. A matching bean is deduplicated by exact declaration class; mismatched rules or a fluent bean without its compiler contribution fail registration. Ordinary imperative validator order and multiplicity remain unchanged. Direct instantiation alone does not register a shared validator. Publish library declarations with their model’s source producer, compiled classes and generated resources together. For a module named `Producer`, the relevant inventory is: | JAR entry | Purpose | | --- | --- | | Model and validator `.class` files | Runtime types and bytecode inventory | | `META-INF/cratis/arc-fluent-validation/Producer.json` | Format-1 declarations authored by this producer; imports are not re-exported | | `META-INF/cratis/arc-fluent-validation-scope/Producer.json` | Format-1 compilation scope, recording its complete declaration set and index status | | `io/cratis/arc/generated/ProducerArcArtifactModule.class` | Compiled module with runtime validator linkage | | `META-INF/services/io.cratis.arc.artifacts.ArcArtifactModule` | ServiceLoader entry naming that module | | `META-INF/cratis/arc/Producer.json` when artifact metadata is emitted | Format-8 artifact descriptors; not a substitute for declaration or scope metadata | Validator-only libraries still need the generated module, declaration resource and ServiceLoader registration; an imported-only root needs a scope and runtime contributions, not a re-exported local declaration resource. Keep these libraries on **both compile and runtime** dependency classpaths, normally via `implementation` (or `api` when their types are part of your library API). Neither `compileOnly` nor `runtimeOnly` alone is sufficient. Scanning reads bytecode headers and constant-pool linkage without loading application classes. Missing/conflicting metadata, missing module or service entries and mismatched compile/runtime inventories fail closed. The compiler parser is packaged with `arc-ksp`, pinned to Kotlin compiler 2.4.20 on JDK 17; it is not an application runtime dependency. ### Manual build tooling Prefer the plugin. If you maintain manual KSP wiring, use the actual [Spring Boot sample build](https://github.com/Cratis/Arc.Kotlin/blob/main/Samples/Kotlin/SpringBoot/build.gradle.kts): its `extractFluentIndex`, `ksp` and `kspKotlin` configuration form one recipe. It resolves compile/runtime **JAR** artifacts, registers those as extraction inputs, declares the output index, makes KSP depend on extraction, passes `fluentIndex.map { it.asFile.toURI().toASCIIString() }`, and registers that file as the nonincremental `arcFluentValidationMetadata` task input with `PathSensitivity.NONE`. It also supplies `arc.validationClasspath` from the main compile classpath’s sorted file URIs and registers that classpath with `ClasspathNormalizer` as the nonincremental `arcValidationClasspath` input. Use the actual matching compilation classpath for other source sets; do not substitute a runtime-only classpath or assume public ABI tracking observes private record annotations. Do not copy only `ksp { arg("arc.moduleName", ...) }` from a basic onboarding build for shared rules. The CLI has exactly **three positional arguments**, no named flags: ```bash java -cp "$ARC_TOOL_CLASSPATH" io.cratis.arc.gradle.ExtractArcFluentValidationMetadataCli \ "$COMPILE_DEPENDENCY_CLASSPATH" "$RUNTIME_DEPENDENCY_CLASSPATH" "$INDEX_FILE" ``` These variables describe inputs you resolve from your build: `ARC_TOOL_CLASSPATH` contains `arc-gradle-plugin` and its tool dependencies; the next two are platform-path-separator-delimited classpath strings for actual compile and runtime dependency artifacts; `INDEX_FILE` is an absolute output filename. This is the CLI invocation shape, not a standalone dependency resolver. The linked sample supplies the actual Gradle resolution/task wiring. Include associated compilation classes and resources when extracting for test or other associated compilations. Do not fabricate an empty index, even when you expect no dependency rules. Pass the resulting **absolute file URI**, not a raw path, as `arc.fluentValidationMetadata`. Set `arc.fluentValidationRoot=true` for main roots, including imported-only applications. The [production native fixture](https://github.com/Cratis/Arc.Kotlin/blob/main/GradlePlugin/src/test/kotlin/io/cratis/arc/gradle/ArcFluentValidationNativeFunctionalTest.kt) exercises the plugin’s producer/consumer/aggregation wiring, dependency changes and recovery. Dependency extraction and proxy discovery have **different inputs**. Extraction runs before KSP on compile and runtime dependency inventories. Proxy generation runs after compilation and needs the complete compiled **root**: its classes/resources and dependency artifacts on `--manifest-classpath`, plus `--module-name` identifying the root module. A manifest-only directory cannot prove runtime registration. The sample’s `generateArcProxies` task shows the complete CLI wiring using main output plus runtime classpath. Root-scope and descriptor checks precede rendering. ## Diagnose by failure stage Do not assume every metadata failure carries a KSP code. Read the failing task first. | Stage | Failure and correction | | --- | --- | | Dependency-index extraction, **before KSP** | An actionable, **uncoded `GradleException`** reports missing/unindexed/conflicting declarations, missing artifacts or runtime registration, or compile/runtime mismatch. Rebuild/repackage producers and supply complete matching dependency inventories. KSP has not run, so this is not `ARCKSP0310`. | | KSP, `ARCKSP0310` | Missing or unusable supplied index, or unavailable parser/configuration. Supply the extracted absolute index URI and nonincremental task input; restore the processor’s packaged parser dependencies. | | KSP, `ARCKSP0308` | Unsupported constructor grammar or potentially shadowed Kotlin class mapping. Use only the restricted body and standard `Model::class.java`; remove/rename conflicting declarations or imports. | | KSP, `ARCKSP0309` | Unsupported member/rule/bound, contradictions, computed or unproved wire members/edges, or ambiguous regex grammar. Use source-proved unchanged members and supported literals, or keep the rule server-only. | | KSP, `ARCKSP0311` | Unsupported annotation target, hidden state, or unrepresentable ignored wire member. Move the annotation to a supported member edge or rename hidden state. | | Proxy generation, `ARCVALIDATION_GRAPH` | Unsupported shared graph or query transport. Use the bounded concrete graph and RFC QUERY, or server-only rules. | | Proxy discovery/root verification | Incomplete root scope, runtime inventory or incompatible descriptors. Supply the complete compiled root and matching dependencies, not manifest-only files. | | Registration/startup | Runtime declaration rules disagree with compiler metadata, or a fluent bean has no generated contribution. Regenerate and register the matching module. | | Runtime, ordinary violation | Inspect the rule message and qualified member path. `execute` rejects before the handler; `validate` never invokes it. | | Runtime, numeric-domain failure | Direct validator throws; the model pipeline returns sanitized `validatorFailed` at the model path, as described above. Do not treat it as an ordinary comparison violation. | Rejected compilation does not publish current partial declaration/manifest aggregates. Extraction does not execute constructors or shadowing extension getters. ## Evidence and limits The executable sources behind the walkthrough are the Kotlin/Java fluent files in `Samples/Kotlin/SpringBoot` and `ContractTests/TypeScript/contracts/runtime.fluent.contract.ts`. `Source`’s `FluentModelValidatorTest` supplies the exact semantic vectors, contradiction and numeric-domain checks. `ArcFluentValidationCompilationTest` proves the restricted Kotlin/Java source grammar, rejected accessors and generated registration. `FluentValidationContractTest` uses the unpublished repository fixtures to exercise generated contributions through the published `CommandScenario` API. `ScenarioFluentValidationTests` counts handler nonexecution in manual-module repository illustrations; it is not a generated-query transport example. `ArcFluentValidationMetadataDiscoveryTest` asserts `GradleException` extraction failures; `ArcFluentValidationNativeFunctionalTest` exercises the production parser and plugin across real producer/consumer compilations. `SharedValidationRenderingTest` covers generated validator naming and composition. These named checks are provenance, not a claim that a documentation verifier runs them. The Kotlin/Java blocking usage adaptations in the guide have not been compiled together as a new tutorial; the documentation gate is a source/link check, not a compiler or runtime gate. # Troubleshooting and FAQ > Common Arc.Kotlin errors and questions, with the fix and where the full contract is documented. ## Build and Gradle ### Gradle can’t find a JDK 17 Arc’s Gradle plugin and generated code target JDK 17 exactly. Make a JDK 17 installation the active `JAVA_HOME`/`PATH` before running `./gradlew`: ```shell export JAVA_HOME=/opt/homebrew/opt/openjdk@17 # path varies by OS and installer export PATH="$JAVA_HOME/bin:$PATH" java -version ``` The sample [`run.sh` scripts](https://github.com/Cratis/Arc.Kotlin/blob/main/README.md#running-the-samples) do this detection automatically. ### `generateArcProxies` fails with “Unsupported Arc artifact manifest format N; expected M” A classpath mixes manifests produced by different Arc KSP versions. Rebuild **every** module that contributes an Arc manifest — both the producer (the module declaring `@Command`/`@ReadModel` types) and every consumer (the module calling `generateArcProxies` against those types) — with the same Arc version. See [Configuration reference](/arc/backend/kotlin/reference/configuration/) for the manifest format and what changes between versions. ### Startup fails with “Arc endpoint-options mismatch” The Gradle plugin’s build-time route settings (`cratisArc.endpoints.*`) disagree with the runtime properties (`cratis.arc.endpoints.*`). The error names the exact setting and both values — align them in `build.gradle.kts` and `application.properties`. See [Configure generation](/arc/backend/kotlin/guides/typescript-proxies/#configure-generation). ### `./gradlew checkSpringBoundary` fails `Source`’s compiled `artifacts`, `metadata`, and `json` packages — and every local type they transitively reference — must stay Spring-free. This check exists to keep the KSP processor and Gradle plugin usable without pulling Spring onto the compiler/build-tool classpath. Move the offending dependency behind an integration module instead of adding it to `Source`. ## Compile-time (`ARCKSP`) errors Every KSP diagnostic carries a stable code and an actionable message, for example: ```text [ARCKSP0101] Command 'CreateTask' must be a public top-level class. ``` The complete catalog, with severity and meaning, is in `CodeGeneration/KSP/DIAGNOSTICS.md` at the repository root. The most common ones: | Code | Usual cause | | --- | --- | | `ARCKSP0100` | A class looks command-shaped (has a public `handle`) but is missing `@Command` | | `ARCKSP0101` / `ARCKSP0200` | A `@Command`/`@ReadModel` class is not a public top-level declaration | | `ARCKSP0102` / `ARCKSP0201` | `handle` or a query method is not public, or is on a private/internal companion | | `ARCKSP0106` | Two members both resolve as the command key | | `ARCKSP0108` | `@AllowAnonymous` combined with `@Authorize`/`@Roles` on the same declaration, or `@AllowAnonymous` on both the class and its operation. Splitting `@AllowAnonymous` and `@Authorize`/`@Roles` across a class and its operation is a supported override, not this error | | `ARCKSP0109` | An aggregate command response leaves more than one possible client-visible leaf | | `ARCKSP0300` | A computed or read-only Kotlin property reached through command input; use a backed property or a separate output model | | `ARCKSP0301` | A Jakarta/Hibernate constraint cannot be represented as a client-side rule | See [Annotation reference](/arc/backend/kotlin/reference/annotations/) for the exact contract each annotation enforces. ## Runtime behavior ### A command or query returns `404` Arc derives each conventional route from the declaring package. `cratis.arc.endpoints.segments-to-skip-for-route` defaults to `0`, so without it a command `CreateTask` in package `example.tasks` is served at `/api/example/tasks/create-task`, not `/api/create-task`. Set the number of leading package segments to drop in `application.properties`, and keep the Gradle plugin’s `cratisArc.endpoints.segmentsToSkip` at the same value so generated clients call the same route. An explicit `@Path` on a query method is used verbatim. See [Configure generation](/arc/backend/kotlin/guides/typescript-proxies/#configure-generation) and the [configuration reference](/arc/backend/kotlin/reference/configuration/). ### A command fails with `reason: "rule"` and `reasonDetail: "commandKey"` The command needs a Chronicle event response but has no usable `@CommandKey`. Declare one backed by `String`, `UUID`, a number, or a concept wrapping one of those. See [Return an event](/arc/backend/kotlin/guides/chronicle/#return-an-event). ### An observable query returns HTTP 202 with no data The query’s source is a cold `Flow` or `Flow.Publisher` that has not produced a value yet. Either back the query with a source that always has a current value (`MutableStateFlow` in Kotlin, `io.cratis.arc.queries.ObservableState` in Java; a plain `SubmissionPublisher` has none), or request `waitForFirstResult=true` on the HTTP snapshot route. See [Consume an observable query](/arc/backend/kotlin/guides/queries/#consume-an-observable-query). ### The Chronicle integration fails at startup with `Could not provision event store ... (authorized=false, ...)` The connected kernel is older than 18.4.0. Kernels 18.3.1 and earlier omit the `IsAuthorized` field from gRPC responses when a request *was* authorized, which a JVM proto3 client decodes as `false`. Upgrade to Chronicle kernel 18.4.0 or newer. The samples pin `cratis/chronicle:18.4.0-development`; the compatibility gate runs `cratis/chronicle:19.1.2-development`. See [Add Chronicle optionally](/arc/backend/kotlin/guides/chronicle/#add-chronicle-optionally). ### A generated TypeScript client rejects a request with a malformed-request error Check for a reserved key (`__proto__`, `prototype`, `constructor`) in a map property, a QUERY body field outside `arguments`/`paging`/`sorting`, or a client argument name that collides with another after case folding — GET and QUERY match argument names case-insensitively. See the [HTTP contract reference](/arc/backend/kotlin/reference/http-contract/#unknown-fields). ### Which sample should I run to see a specific behavior? * No external dependencies, plain commands and queries: `Samples/Kotlin/SpringBoot` or `Samples/Java/SpringBoot`. * Chronicle events, concurrency, and tenant-scoped read models: `Samples/Kotlin/ChronicleSpringBoot` or `Samples/Java/ChronicleSpringBoot`. Each has a `run.sh` — see [Running the samples](https://github.com/Cratis/Arc.Kotlin/blob/main/README.md#running-the-samples). ## “Is this feature really implemented?” Every implementation claim in this documentation is backed by a test, contract test, or runnable sample named in the [feature parity reference](/arc/backend/kotlin/reference/parity/). If a guide describes a behavior and you want to know exactly what proves it — or whether it matches Arc on .NET — that document is the source of truth, not this page or the README. ## Still stuck? Open a [GitHub issue](https://github.com/Cratis/Arc.Kotlin/issues) with the exact error message, the Arc version, and a minimal reproduction. The [Cratis Discord](https://discord.gg/kt4AMpV8WV) is the place for open-ended questions. # Why Arc for Kotlin and Java > The endpoint plumbing Arc removes from a Spring Boot application, what you get in return, and when a different approach fits better. ## The problem: every endpoint repeats the same plumbing A conventional Spring Boot application writes the same shape over and over for every command and every query: a `@RestController` method, a request DTO, a `BindingResult` check, a call into a service, a response DTO, and — if the frontend is TypeScript — a hand-written or generated client that has to be kept in sync by hand. None of that is the behavior you actually wanted to write. It is the cost of getting a plain Kotlin or Java class across HTTP. ## What Arc does instead You put behavior on the model itself. A class annotated `@Command` with a public `handle` method *is* the command, the validation, and the endpoint. A class annotated `@ReadModel` with a static or companion query method *is* the read model and the endpoint that serves it. [KSP](https://kotlinlang.org/docs/ksp-overview.html) reads those annotations at compile time and generates a reflection-free handler, a route, request and response schemas, Jakarta validation wiring, and a strict-mode TypeScript client — all before the application ever starts. ``` optional event response HTTP or generated TypeScript client Arc Spring Boot integration Command or query pipeline KSP-generated handler or performer Model-bound Kotlin or Java code Chronicle integration ``` Nothing here is reflection at request time, and nothing is hand-registered: KSP discovers every `@Command` and `@ReadModel` in the sources it compiles, and each module’s generated `ArcArtifactModule`, including those in dependency JARs, is found through `ServiceLoader` when the application starts. ## What you get | Without Arc | With Arc | | --- | --- | | A controller method per command/query, plus a service call | One annotated class; `handle` or the query method *is* the behavior | | Manual `@Valid`/`BindingResult` checks, repeated per endpoint | Automatic Jakarta validation on command and query-argument graphs, plus reusable `ConceptValidator`/`ModelValidator` rules | | A hand-maintained OpenAPI spec or TypeScript client | A generated, strict-mode TypeScript client from the same compile-time metadata, kept current by a Gradle task | | Bespoke polling or a hand-rolled SSE/WebSocket endpoint for live data | `Flow`/`Flow.Publisher` query return types become HTTP snapshots, SSE, and WebSocket automatically | | A second `AuthorizationHandler`-per-controller pattern | `@Authorize`, `@Roles`, and `@AllowAnonymous` on the command/query, evaluated by one pipeline | | Ad hoc wiring for an event-sourced write path | An optional Chronicle integration: return events from `handle`, and Arc stages and commits them | See [What Arc.Kotlin owns](https://github.com/Cratis/Arc.Kotlin/blob/main/README.md#what-arckotlin-owns) for the complete boundary and the [feature parity reference](/arc/backend/kotlin/reference/parity/) for the evidence behind every specific claim. ## When Arc is not the right fit * **You need a non-Spring host, or hand-written controllers.** Arc targets Spring Boot exclusively and generates model-bound MVC endpoints; neither another host nor controller-based endpoints are planned. See [Non-Spring hosting](/arc/backend/kotlin/reference/parity/) and [Controllers](/arc/backend/kotlin/reference/parity/) in the parity matrix. * **You need raw-output compatibility with Arc on .NET.** Arc.Kotlin implements the same ideas with JVM-native choices (coroutines and `CompletionStage` instead of `async`/`await`, `Flow` instead of `IObservable`). The [parity reference](/arc/backend/kotlin/reference/parity/) tracks exactly which behaviors are equivalent and which intentionally diverge — read it before assuming byte-for-byte compatibility. * **You do not want event sourcing, and you also do not want Spring Data JPA/MongoDB.** Arc.Core has no dependency on Chronicle or on the Spring Data integrations, but if your application has no persistence story at all, Arc still assumes Spring Boot as the host. ## Where to go next * [Build your first Arc application in Kotlin](/arc/backend/kotlin/get-started/) or [in Java](/arc/backend/kotlin/get-started/java/) — a runnable command and query in about five minutes. * [Coming from Spring MVC](/arc/backend/kotlin/coming-from-spring-mvc/) — map familiar `@RestController` patterns onto Arc’s model-bound commands and queries. * [Commands](/arc/backend/kotlin/guides/commands/), [queries](/arc/backend/kotlin/guides/queries/) and [validation](/arc/backend/kotlin/guides/validation/) cover the everyday work. # Arc for TypeScript > Arc for TypeScript brings the Arc CQRS server model - model-bound commands, read models, validation, and authorization - to Node.js, speaking the same HTTP contract as Arc on .NET. Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Cratis CQRS framework: you declare commands and read models as decorated classes, and Arc hosts them behind one HTTP contract that the existing Arc clients already understand. Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source. Source preview, no full parity No package is published to npm; the manifests are at version 0.58.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](/arc/backend/typescript/reference/capabilities/) is the single place for status and evidence. ## What it looks like ```typescript @command() export class RegisterTask { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; handle(tasks: Tasks): TaskId { tasks.register(this.id, this.title); return this.id; } } @readModel() export class TaskItem { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; @query(service(Tasks)) static allTasks(tasks: Tasks): TaskItem[] { return tasks.all(); } } ``` These excerpts are from the [Tasks sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Tasks). The sample serves them at `POST /api/tasks/registration/register-task` and `GET /api/tasks/listing/all-tasks`, with no routing code. [Generated metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) tells Arc that `handle()` needs the `Tasks` service. [Get started](/arc/backend/typescript/getting-started/) runs it in a few commands. ## What you get * **Model-bound commands and read models** with typed fields, concepts, `provide()`, [command context](/arc/backend/typescript/commands/command-context/), [response value handlers](/arc/backend/typescript/commands/response-value-handlers/), and [operations](/arc/backend/typescript/commands/operations/) that Arc executes and compensates. * **Validation** with `CommandValidator`, `QueryValidator`, and `ConceptValidator` rules, and a `/validate` route for every command. * **Queries** with argument binding, paging and sorting, [renderers and interceptors](/arc/backend/typescript/queries/query-pipeline/), and [observable queries](/arc/backend/typescript/queries/observable-queries/) over server-sent events, WebSockets, and multiplexed hubs. * **Security**: [authentication handlers](/arc/backend/typescript/core/authentication/) including JWT bearer and EasyAuth, [roles and policies](/arc/backend/typescript/authorizing-commands-and-queries/), [identity details](/arc/backend/typescript/identity/), and [tenancy](/arc/backend/typescript/tenancy/). * **Hosting** on Node’s own HTTP server, or in [Express, Fastify, or Hono](/arc/backend/typescript/hosts/). * **Generated proxies** for the published `@cratis/arc` client, read from your TypeScript source by [`arc-proxygenerator`](/arc/backend/typescript/proxy-generation/). * **Testing** through the real pipelines with [scenarios](/arc/backend/typescript/testing/), and **ESLint rules** that catch binding mistakes in the editor with [code analysis](/arc/backend/typescript/code-analysis/). * **Optional integrations**: [MongoDB](/arc/backend/typescript/mongodb/), [SQL with Drizzle](/arc/backend/typescript/sql/), [OpenTelemetry observability](/arc/backend/typescript/observability/), and the experimental [Chronicle](/arc/backend/typescript/chronicle/) event store. ## CQRS first, event sourcing optional Arc is a CQRS framework. A command can validate input, call a service, write to current-state storage, and return a response without any event log. The core has no dependency on event sourcing or on a database. The Chronicle integration is a separate, experimental package; see [CQRS without event sourcing](/arc/arc-without-event-sourcing/) for how that boundary works in Arc generally. ## A server for the clients you already have Arc’s TypeScript **client** packages, `@cratis/arc`, `@cratis/arc.react`, and `@cratis/arc.react.mvvm`, are built and released from the [Arc repository](https://github.com/Cratis/Arc). This project does not replace, rename, or republish them; they are its compatibility target. The server packages are listed in [Packages](/arc/backend/typescript/reference/packages/). See [Frontend](/arc/frontend/) for the client side. ## One wire contract Arc on .NET is the reference implementation, and the language-neutral [Arc HTTP contract](/arc/http-contract/) is the specification. Arc for TypeScript matches that observable behavior in idiomatic TypeScript; it does not port .NET mechanics such as attribute reflection or dependency injection containers. A [paired suite](/arc/backend/typescript/reference/capabilities/#how-parity-is-checked) sends the same requests to a .NET host on `Cratis.Arc` 22.45.0 and pins the known differences. The largest deliberate one: an HTTP client cannot use `X-Allowed-Severity: 3` to let error-severity validation results pass. See the [HTTP contract reference](/arc/backend/typescript/reference/http-contract/). ## On the shared Arc pages The shared Arc pages, such as the [tutorial](/arc/tutorial/first-slice/) and the scenarios, show a TypeScript tab beside C#, Kotlin, and Java. The TypeScript snippets use the model-bound API and are compiled against this repository’s packages. Read them with these differences in mind: * Repositories and catalogs in the snippets, such as `AuthorRepository`, are application-owned abstract classes that you implement and register with `builder.services`. An abstract class serves as its own service token; an interface does not exist at runtime. * An observable query declares `@query({ observable: true }, ...)` in addition to returning an observable source. * In the snippets, `provide()` takes no parameters: it resolves services with `currentServices()` and reads the request’s cancellation signal from `currentContext()`. You can also declare its parameters with `@inject(...)`, as [Model-bound commands](/arc/backend/typescript/commands/model-bound/#prepare-data-in-provide) shows. * A Chronicle command scenario builds reducer-backed and supported flat projection-backed read models from seeded events; projections outside that boundary are rejected. Test those with a [kernel scenario](/arc/backend/typescript/testing/chronicle-kernel/). * The shared validator examples inject an application-owned repository. Arc for TypeScript validators do not take read models as constructor parameters; read one inside an async rule with `readModelForValidation`. * Proxy generation on those pages describes the C# and JVM generators. Arc for TypeScript generates the same kind of proxies from TypeScript source; [Proxy generation](/arc/backend/typescript/proxy-generation/) lists what differs. ## Releases Arc for TypeScript is versioned independently of Arc on .NET. GitHub source previews are available; npm publication is disabled. A major release is never made automatically: it requires verified full parity with Arc on .NET and an explicit merge by a maintainer. See [Prepare and publish a TypeScript release](/arc/backend/typescript/contributing/releases/). ## Where to go next * [Why Arc for TypeScript](/arc/backend/typescript/why-arc-for-typescript/): who it is for, what it removes, and when it is the wrong fit. * [Get started](/arc/backend/typescript/getting-started/): run the Tasks sample and call its command and query. * [Coming from Express and NestJS](/arc/backend/typescript/coming-from-express-and-nestjs/): compare Arc with the code you write today. * [Hosting overview](/arc/backend/typescript/overview/): choose the standalone host or a framework adapter. * [Architecture](/arc/backend/typescript/architecture/): the core, the adapters, and how Arc concepts map to TypeScript. * [Troubleshooting](/arc/backend/typescript/troubleshooting/): fixes for the common decorator, discovery, and hosting problems. # Architecture > How Arc for TypeScript separates a framework-independent CQRS core from host adapters and optional integrations, and how Arc concepts map to TypeScript and Node.js. Arc for TypeScript is built so the same command and query code can run behind Express, Fastify, or Hono, with or without a database or an event store. This page explains the boundaries that make that possible and the places where TypeScript forces a different design from Arc on .NET. Source preview Package names are working names, and APIs are not final. For what is supported today, see the [capability reference](/arc/backend/typescript/reference/capabilities/). ## Three layers ``` Node.js process Arc HTTP contract optional experimental Arc clients @cratis/arc, generated proxies Host adapter Express, Fastify, or Hono Arc core @cratis/arc.core Your commands and queries MongoDB or SQL read models Chronicle event store ``` * **The core** owns everything that defines Arc behavior: the command and query pipelines, route conventions, result envelopes, validation, authorization, authentication handlers, correlation, and tenancy. It does not import an HTTP framework or a storage driver. * **A host adapter** translates between one HTTP framework and the core. It routes matching requests to the core, hands over the raw body and a cancellation signal, and writes the response. It adds no Arc behavior of its own. * **Integrations** give commands and queries somewhere to read and write. They are separate packages that depend on the core, never the other way around. `@cratis/arc.mongodb` serves tenant-scoped MongoDB collections, `@cratis/arc.drizzle` serves tenant-scoped SQL reads, and `@cratis/arc.chronicle`, experimental and not published to npm, appends events returned from a command; its live-kernel suite covers Express, Fastify, and Hono. It supports command-scoped event batches, aggregates, and reactor-returned commands; see the [Chronicle capability reference](/arc/backend/typescript/reference/capabilities/#persistence-and-chronicle) for remaining limits. * **Build-time tooling** sits beside the runtime. `@cratis/arc.proxygenerator` reads your TypeScript source and writes proxies for the published `@cratis/arc` client, and `@cratis/eslint-plugin-arc-core` checks decorated artifacts in the editor. The core does not depend on `@cratis/arc` or on browser code. Keeping the core framework-independent means a behavior is implemented and tested once, and every adapter inherits it. A difference between adapters is either a limit of the host framework, documented per adapter, or a bug. ## What the core owns and what an adapter owns | Concern | Core | Host adapter | | --- | --- | --- | | Routes and methods | Derives routes from declarations; decides which methods each route accepts | Express and Fastify send only requests whose raw path exactly matches an Arc route; Hono hands every request to the core, which matches the request URL’s path. Every other request stays with the application | | Request bodies and query strings | Enforces the size limit, parses, binds, and validates; turns malformed input into a `malformedRequest` result | Hands the body over unparsed | | Command and query execution | Runs the pipeline, including authorization before validation | Nothing | | Results and status codes | Builds the envelope and selects the status code | Writes the status, headers, and body | | Correlation and tenancy | Resolves the correlation ID and tenant, and makes them available to the running operation | Nothing | | Authentication and authorization | Runs the configured authentication handlers, then evaluates authorization against the principal | Nothing by default. With `nativePrincipal: true`, passes on a principal the host already verified, through an explicit callback | | Cancellation | Passes the signal to every callback as `context.signal` | Express and Fastify abort it when the client disconnects; Hono passes the request’s own signal | Observable queries use the same split. The core owns the subscription pipeline, snapshots, server-sent events, and the WebSocket protocol. Each adapter owns how a WebSocket upgrade reaches the core, because Express, Fastify, and Hono accept upgrades differently; [WebSockets](/arc/backend/typescript/hosts/websockets/) shows each one. ## The request path Arc on .NET defines the order in which a request is processed, and the TypeScript core follows it: 1. The adapter receives the request and hands it to the core. 2. The core resolves the correlation ID, runs authentication handlers, and resolves the tenant. 3. Authorization runs first. A denied caller gets 401 or 403 and no validation output, so rule messages never leak to a caller who may not run the operation. 4. Validation runs. `POST /validate` stops here and never runs the handler. 5. For a command, execution scopes begin, `provide` and `handle` run, and the scopes complete. For a query, `perform` runs, and an array result is sorted and paged. 6. The core builds the result envelope and selects the status code in the contract’s order: 200, then 403, 400, 202, and 500. A result that fails at any step never carries a response value. Outside development, exception messages and stack traces are replaced before serialization, and the correlation ID stays. ## Idiomatic TypeScript, not a port Parity means the same observable behavior on the wire, not the same implementation. Several .NET mechanisms have no direct TypeScript equivalent: | Arc on .NET relies on | Arc for TypeScript | | --- | --- | | Attributes and runtime reflection (`[Command]`, `[ReadModel]`, parameter types) | Decorators (`@command()`, `@readModel()`, `@query()`, Fundamentals `@field`). TypeScript erases types at runtime, so fields name their wire type, and query parameters and injected services are listed in order. The builder discovers decorated classes in a folder, or you add them explicitly. Zod-backed `defineCommand` and `defineQuery` remain the low-level path | | Dependency injection with per-request scopes | Class or `serviceToken` tokens with singleton, scoped, or transient registrations. Each operation owns a scope; singleton construction belongs to the registry and receives no request identity. There is no integration with another container | | `AsyncLocal` ambient context | Node.js `AsyncLocalStorage`, with a frozen context per request or direct call, so one request’s principal, tenant, or correlation never leaks into another | | `CancellationToken` | `AbortSignal` | | `IObservable` and `ISubject` | RxJS `BehaviorSubject` (200 current snapshot), `Subject`/`Observable` (202 pending snapshot), an async iterable, or a structural subscribable. `CurrentValueSubject` remains available but is deprecated | | `IQueryable` paging and sorting | In-memory paging and sorting of arrays, or a page the data source already cut, returned with `queryPage` | | FluentValidation and DataAnnotations | Field types for shape, and `CommandValidator`, `QueryValidator`, and `ConceptValidator` classes with `ruleFor` rules. The low-level path uses Zod schemas and validator functions | | Roslyn analyzers and a proxy generator that reads compiled assemblies | ESLint rules for decorated artifacts, and a generator that reads your TypeScript source through the compiler API without running it. See [Code analysis](/arc/backend/typescript/code-analysis/) and [Proxy generation](/arc/backend/typescript/proxy-generation/) | Some differences are in the language itself and affect the wire: * JavaScript numbers are 64-bit floating point. Integers above 2^53 − 1 lose precision unless they travel as strings. * `undefined` and `null` are different values, and JSON has no `undefined`. Omitted and explicit-null input have to be handled deliberately. * Dates, times, and GUIDs cross the wire as strings. `@cratis/fundamentals` provides the `DateOnly`, `TimeOnly`, and `Guid` types the generated clients use. * Node.js runs one event loop per process. A handler that blocks the loop blocks every request. ## Safety choices that differ from Arc on .NET Where following Arc on .NET exactly would let a remote caller weaken a check, Arc for TypeScript chooses the safer behavior and documents it: * An HTTP client cannot raise the allowed validation severity to `Error`, so business-rule errors always block. Only trusted code calling `executeCommand` can. * Tenant and security checks belong in `authorize`, which no severity setting affects. * A configured tenant resolver is final; there is no silent fallback to a header. * Unsafe names, paths, body limits, and contradictory authorization declarations stop the server at startup. The full list is in the [capability reference](/arc/backend/typescript/reference/capabilities/#deliberate-differences). ## Integrations stay outside the core Arc on .NET adds event sourcing through its Chronicle integration: a command returns events, and they are appended only when the command succeeds. Arc for TypeScript keeps the same boundary. The core never depends on Chronicle, and the experimental integration is a separate package built on the Chronicle TypeScript client, tested with `@cratis/chronicle` 6.19.0, through the core’s response value handler and command read-model extension points. Namespace, correlation, and event routing are passed explicitly per request. The integration covers the main Chronicle command paths. Events returned by a command and events applied to a keyed [aggregate](/arc/backend/typescript/chronicle/aggregates/) are staged, together with those of nested commands, and appended in one `appendMany` batch after the outer command succeeds. A Chronicle reactor can return Arc commands, which run through the full command pipeline. An opt-in live-kernel suite exercises returned events, batches, aggregates, reactor commands, and concurrency rejections through Express, Fastify, and Hono. The integration stays experimental, and full parity with Arc on .NET is unverified. The known gaps: * The batch covers one event log. It is not a transaction across other stores or external calls, and an immediate SDK append inside `handle()` is outside it. See [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/). * The aggregate loads only the event source named by the command key, and has no `Failed(...)` or `OnActivate`. * The SDK replays reactors by default since 6.9.0. Mark non-replayable effects with `@onceOnly()`; returned commands still need to tolerate failed-partition re-delivery. See [Reactors](/arc/backend/typescript/chronicle/reactors/). * Reads through Chronicle return decrypted read models. Arc releases encrypted personal data at its query edge only for protected read models decoded into their exact class from a direct MongoDB read; raw documents, derived subtypes, and mapped objects need an explicit `readModels.release` call. See [Compliance](/arc/backend/typescript/chronicle/compliance/). * There are no `ARCCHR` analyzers. See [Chronicle](/arc/backend/typescript/chronicle/). The MongoDB and Drizzle integrations follow the same rule: the application owns the client or database, the tenant mapping, and the filter or predicate. See [MongoDB](/arc/backend/typescript/mongodb/) and [SQL with Drizzle](/arc/backend/typescript/sql/). ## Related * [Arc HTTP contract](/arc/http-contract/) * [Capability reference](/arc/backend/typescript/reference/capabilities/) * [Understanding the proxy boundary](/arc/understanding-the-proxy-boundary/) * [CQRS without event sourcing](/arc/arc-without-event-sourcing/) # Authorizing commands and queries > Restrict commands and queries by authentication, role, policy, or per-request decision, and see which status each check answers and in what order. A command that renames a task has to answer several questions before it touches anything: who is calling, may they call this at all, may they change this particular task, and is the input valid? Arc gives each question its own place and always asks them in the same order, so a caller the declarations turn away never sees the command’s rule messages. ## Declare who may call Put the rule on the command class or the read model as a decorator: | Decorator | Who may call | | --- | --- | | None | Everyone, including anonymous callers | | `@authorize()` | Any authenticated caller | | `@roles('editor', 'admin')` | An authenticated caller with at least one of the roles | | `@authorize({ policy: 'Finance' })` | A caller the registered [policy](/arc/backend/typescript/core/authorization/) accepts | | `@authorize({ schemes: ['Verified'] })` | A principal authenticated by the named [scheme](/arc/backend/typescript/core/authorization/#select-an-authentication-scheme) | | `@allowAnonymous()` | Everyone, stated explicitly | * Roles in one declaration are alternatives; stacked declarations must **all** pass. * On a read model, an explicit `@query()` method declaration replaces the class declaration; without one, the method inherits the class declaration. * Combining anonymous access with an authenticated requirement is a contradiction, and startup throws. * Declare command authorization on the class. Any authorization decorator on a command method (`handle()`, `provide()`, or a helper), or on a static method without `@query()`, fails at build instead of silently having no effect. See [Command authorization](/arc/backend/typescript/commands/model-bound/authorization/) for more command examples. ## A command with every check This command requires the `editor` role, validates the title, and lets only the task’s owner in the current tenant rename it. The bearer token is a fixed development value, not real token verification. tasks.ts ```typescript import { field } from '@cratis/fundamentals'; import { ArcApplication, AuthenticationStatus, command, commandContext, CommandValidator, denied, inject, rejected, roles, validation, validator, type AuthenticationHandler, type CommandContext } from '@cratis/arc.core'; interface Task { id: string; tenant: string; owner: string; title: string } const tasks = new Map(); @command({ namespace: 'Tasks' }) @roles('editor') export class Rename { @field(String) id!: string; @field(String) title!: string; @inject(commandContext()) provide(context: CommandContext) { const task = tasks.get(this.id); if (!task) return rejected(validation('The task does not exist', ['id'], 'notFound')); const owned = context.tenantId !== undefined && task.tenant === context.tenantId && task.owner === context.principal?.id; return owned ? task : denied('Only the owner can rename a task'); } handle(task: Task) { task.title = this.title; return { id: task.id }; } } @validator(Rename) export class RenameValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.title).notEmpty().withMessage('A title is required'); } } // Development only: fixed tokens instead of real token verification. const developmentUsers = new Map([['ada-dev-token', { id: 'ada', roles: ['editor'] }]]); const bearerToken: AuthenticationHandler = request => { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return { status: AuthenticationStatus.Anonymous }; const user = developmentUsers.get(header.slice('Bearer '.length)); if (!user) return { status: AuthenticationStatus.Failed }; return { status: AuthenticationStatus.Authenticated, principal: { ...user, isAuthenticated: true } }; }; const builder = ArcApplication.createBuilder({ authentication: [bearerToken] }); builder.add(Rename, RenameValidator); export const app = await builder.build(); ``` With one task owned by `ada` in tenant `acme` and one owned by someone else, `POST /api/tasks/rename` answers: | Request | Status | Why | | --- | --- | --- | | No `Authorization` header | 401 | The command requires a role and nobody is authenticated | | An unknown bearer token | 401 | The handler returned `Failed` | | Ada’s token, no `x-cratis-tenant-id` header | 403 | `provide()` returned `denied(...)`; the reason is in `authorizationFailureReason` | | Ada’s token, tenant `acme`, someone else’s task | 403 | `provide()` returned `denied(...)` | | Ada’s token, tenant `acme`, a title of spaces | 400 | The validator returned a result for `title` | | Ada’s token, tenant `acme`, an unknown task ID | 400 | `provide()` returned `rejected(...)` with reason `notFound` | | Ada’s token, tenant `acme`, her own task, a new title | 200 | `response` is `{ "id": ... }` | `provide()` runs after validation, so an editor who sends a title of spaces for someone else’s task gets 400 with the rule message, not 403. When a per-input decision must come before validation, use a low-level definition’s `authorize` callback, described in [Decide per request](#decide-per-request). The full order of stages is on [Command pipeline](/arc/backend/typescript/commands/command-pipeline/); queries follow the same order, described on [Query pipeline](/arc/backend/typescript/queries/query-pipeline/). ## Status codes * 401 answers an anonymous caller on a protected operation when at least one authentication handler is configured, and any request whose handler returned `Failed`. Arc on .NET answers 403 for the anonymous case; this is a [deliberate difference](/arc/backend/typescript/reference/capabilities/#deliberate-differences). * 403 answers a known caller who does not meet a declaration, a per-request `authorize` that returned `false`, or `denied(...)`. * Unparseable JSON is rejected with 400 before the role check, so an authenticated caller without the role gets 400 for a malformed body and 403 for a well-formed one. ## Queries: roles and ownership A model-bound query takes the same decorators on its read-model class or on a `@query()` method. An explicit method declaration replaces the class’s entirely: `@roles('Reader')` on a method of an `@roles('Admin')` class permits Reader, not Admin. Without method decorators, the class declaration applies. A denied caller never reaches the query method and gets `isAuthorized: false`. A role answers “may this caller use the query at all”, not “which rows may they see”. `@roles('Planner')` on `allTasks` lets every planner read every task. When a read is owner-scoped, make ownership part of the query itself: read the caller’s identity with `currentContext()` from `@cratis/arc.core` and put it in the data source’s filter, next to the requested ID. When the caller has no identity, deny the query; never drop the owner filter to make it work. [Observable queries](/arc/backend/typescript/queries/observable-queries/#authorize-a-live-query) shows an owner-filtered live query. For a cross-cutting query rule, use a scoped [authorization query filter](/arc/backend/typescript/queries/query-filters/) instead of repeating it on each query. It runs after declared authorization and argument binding, before validators or performer dependencies; `unauthorizedQueryResult(context)` answers 403 without a reason or validation details. Snapshot GET/`QUERY` and direct or hub subscriptions use the same admission pipeline. For a live query, authorization filters run once when the subscription opens. A role removed later does not close a running subscription; use an [emission guard](/arc/backend/typescript/queries/observable-query-emission-guards/) when access must be re-checked on every emission. ## Keep security out of validators Put every security and tenant check in a declaration, a policy, `provide()`, or `authorize`, never in a validator. A trusted direct caller can lower the blocking severity, but nothing lowers authorization or `denied(...)`. ## Test who may call Authorization is easy to break silently: a moved decorator or a new command without one leaves an operation open, and nothing fails. Specify it like any other behavior. A scenario’s `withContext({ principal })` sets the caller, and `shouldNotBeAuthorized()` and `shouldBeAuthorized()` assert the verdict. [Testing commands](/arc/backend/typescript/testing/commands/#test-authorization) shows the specs for an anonymous caller, a caller without the role, and a caller with it. ## Decide per request A decorator cannot see which task the caller wants. A model-bound command makes that decision in `provide()` by returning `denied(reason)`, which answers 403 with the reason, as the example above does; see [Command outcomes](/arc/backend/typescript/commands/command-outcomes/). A low-level definition adds `authorize(input, context)` instead. It runs after the schema and before validation, receives the typed input, and returns `true` or `false`, or a promise of either. `false` answers 403 without a reason. Nothing a caller sends, and no allowed severity, changes its outcome. ## Low-level definitions `defineCommand` and `defineQuery` take an `authorization` property instead of decorators: | Decorator | Low-level `authorization` | | --- | --- | | None | None | | `@authorize()` | `{ authenticated: true }` | | `@roles('editor', 'admin')` | `{ roles: ['editor', 'admin'] }` | | `@authorize({ policy: 'Finance' })` | `{ policy: 'Finance', authenticated: true }` | | `@authorize({ schemes: ['Verified'] })` | `{ schemes: ['Verified'], authenticated: true }` | | `@allowAnonymous()` | `{ anonymous: true }`; a per-request `authorize` callback still runs | The same rename as a low-level command, with the ownership check in `authorize`: low-level-tasks.ts ```typescript import { ArcServer, AuthenticationStatus, defineCommand, rejected, response, validation } from '@cratis/arc.core'; import type { AuthenticationHandler } from '@cratis/arc.core'; import { z } from 'zod'; interface Task { id: string; tenant: string; owner: string; title: string } const tasks = new Map(); // Development only: fixed tokens instead of real token verification. const developmentUsers = new Map([['ada-dev-token', { id: 'ada', roles: ['editor'] }]]); const bearerToken: AuthenticationHandler = request => { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return { status: AuthenticationStatus.Anonymous }; const user = developmentUsers.get(header.slice('Bearer '.length)); if (!user) return { status: AuthenticationStatus.Failed }; return { status: AuthenticationStatus.Authenticated, principal: { ...user, isAuthenticated: true } }; }; const rename = defineCommand({ name: 'Rename', namespace: 'Tasks', schema: z.object({ id: z.string(), title: z.string() }), authorization: { roles: ['editor'] }, authorize: ({ id }, context) => { if (!context.tenantId) return false; const task = tasks.get(id); return !task || (task.tenant === context.tenantId && task.owner === context.principal?.id); }, validate: ({ title }) => title.trim() ? [] : [validation('A title is required', ['title'])], provide: ({ id }) => { const task = tasks.get(id); return task ? response(task) : rejected(validation('The task does not exist', ['id'], 'notFound')); }, handle: ({ title }, _context, provided) => { const task = provided as Task; task.title = title; return { id: task.id }; } }); export const arc = new ArcServer({ commands: [rename], authentication: [bearerToken] }); ``` It answers the same statuses as the model-bound command, with two differences. Its 403 responses carry no reason, because `authorize` returns only `false`. And a title of spaces on someone else’s task answers 403, not 400, because `authorize` runs before `validate`. ## Related * [Authentication](/arc/backend/typescript/core/authentication/) * [Authorization policies and schemes](/arc/backend/typescript/core/authorization/) * [Tenancy](/arc/backend/typescript/tenancy/) # Chronicle > Return Chronicle events from Arc commands, serve projected read models from Arc queries, and let reactors return Arc commands, with the experimental @cratis/arc.chronicle integration. A librarian registers an author. You want that registration kept as a fact, and you want the author list on screen to update from it. Without an integration, every command opens a Chronicle client, picks the event store namespace for the caller’s tenant, appends inside `handle()`, turns constraint violations into validation results, and takes care never to append when validation has already failed. `@cratis/arc.chronicle` removes that plumbing. A command **returns** the event, and Arc appends it after authorization, validation, and `provide()` have passed, in the namespace of the tenant Arc already resolved. Arc does not require event sourcing: a command can do its work through any service. Use this integration when you want commands to record facts in [Chronicle](/chronicle/), the event-sourcing database. Experimental `@cratis/arc.chronicle` is experimental, and, like every package in this repository, it is not published to npm. Its APIs can change. The [capability reference](/arc/backend/typescript/reference/capabilities/#persistence-and-chronicle) has its status and the checks behind it. To try it, [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/) starts a Chronicle kernel on your machine and adds a first event-sourced slice to the application from [Create an application](/arc/backend/typescript/getting-started/create-an-application/). ## How the pieces fit Arc and Chronicle meet at one loop. A command returns an event, Chronicle appends it and projects it into a read model, and an Arc query serves that read model back to the client. ``` command returns an event projection query reactor command Client @command() class · handle() Chronicle event log @readModel() · @fromEvent @reactor() · returns commands ``` The [Library sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Library) walks that loop. Registration returns the event: Features/Authors/Registration/Registration.ts (excerpt) ```typescript @eventType() export class AuthorRegistered { @field(AuthorName) name: AuthorName; constructor(name: AuthorName = new AuthorName('')) { this.name = name; } } @command() @roles('Librarian') export class RegisterAuthor { @key() @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; handle(): AuthorRegistered { return new AuthorRegistered(this.name); } } ``` The listing projects that event into a read model and serves it: Features/Authors/Listing/Listing.ts (excerpt) ```typescript @readModel() @fromEvent(AuthorRegistered) export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @query({ observable: true }, service(ChronicleReadModels)) static allAuthors(models: ChronicleReadModels): Observable { return models.observeAll(Author, author => author.id.toString()); } } ``` The `@key()` field names the event source, so the registration lands in that author’s stream. `@fromEvent(AuthorRegistered)` asks Chronicle to copy the event’s matching properties into `Author`, keyed by the event source. The observable query then pushes a new author list to the browser whenever the projection changes. Appending and projecting are separate steps inside Chronicle, so a successful command can return before the list has caught up. ## What the integration adds * **Returned events are appended.** `handle()` returns one event, several, or events beside a response. See [Returning events](/arc/backend/typescript/chronicle/commands/). * **Metadata comes from the command.** The event source, stream, subject, and causation are resolved from the command and the request. See [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/). * **Tenants map to namespaces.** Every append and read uses the tenant of the current execution as the Chronicle namespace. * **Rejections become validation results.** A constraint or concurrency violation answers 400, and nothing is appended. * **Nested commands share one batch.** Returned events from nested commands are appended together, or not at all. See [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/). * **Current state is a parameter.** A command can take its own read model or a rehydrated aggregate as a `handle()` argument. See [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/) and [Aggregates](/arc/backend/typescript/chronicle/aggregates/). * **Reactors can return commands.** A Chronicle reactor returns an Arc command, and Arc runs it through the full command pipeline. See [Reactors](/arc/backend/typescript/chronicle/reactors/). ## Find your way | Page | Use it when you want to | | --- | --- | | [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/) | Start a local kernel, record a first event from a command, and query its projection | | [Registration options](/arc/backend/typescript/chronicle/registration-options/) | Look up the `withChronicle` options, configuration keys, and client ownership | | [The Cratis package](/arc/backend/typescript/chronicle/cratis-package/) | Register Arc and Chronicle with one call from `@cratis/cratis` | | [Returning events](/arc/backend/typescript/chronicle/commands/) | Return one event, a batch, or events next to a response | | [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/) | See what each appended event carries and where every value comes from | | [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/) | Choose which event source an event is appended to, and route it | | [Subject](/arc/backend/typescript/chronicle/commands/subject/) | Record whose personal data an event carries | | [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/) | Reject an append when the stream moved | | [Causation and auditing](/arc/backend/typescript/chronicle/commands/causation/) | See what the permanent causation chain records, and keep secrets out | | [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/) | Understand the batch across nested commands and its failure rules | | [Read models](/arc/backend/typescript/chronicle/read-models/) | Serve projected state from queries | | [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/) | Decide or validate from the state projected for the command’s key | | [When read model resolution fails](/arc/backend/typescript/chronicle/read-models/failures/) | Understand a rejected command that loads a read model | | [Aggregates](/arc/backend/typescript/chronicle/aggregates/) | Decide from one event source’s full history | | [Reactors](/arc/backend/typescript/chronicle/reactors/) | Run a follow-up command when an event is recorded | | [Compliance](/arc/backend/typescript/chronicle/compliance/) | Know what the integration does, and does not do, for personal data | | [Code analysis](/arc/backend/typescript/chronicle/code-analysis/) | See which .NET `ARCCHR` diagnostics apply in TypeScript | | [Testing Chronicle commands](/arc/backend/typescript/testing/chronicle/) | Assert returned events without a kernel | | [Testing against a kernel](/arc/backend/typescript/testing/chronicle-kernel/) | Check projections and constraints against a live Chronicle kernel | | [Read consistency](/arc/backend/typescript/queries/read-consistency/) | Select passive reads or wait for observer completion after a write | ## Where it stops * A command’s events go to one event log in one event store. There is no transaction across other stores or external calls. * An event appended directly through the SDK inside `handle()` is outside the command’s batch. * Read models you read through Chronicle arrive already decrypted. Arc releases encrypted personal data at its query edge only for a protected Chronicle read model read directly from MongoDB and returned by a query, either as an instance of its exact class or as a raw `MongoReadModels` document typed with `readModel`. Untyped raw documents, derived subtypes, and mapped objects are served as stored unless you call `readModels.release` yourself. See [Compliance](/arc/backend/typescript/chronicle/compliance/). * SDK 6.9.0 and later replay reactors by default. Mark non-replayable effects with `@onceOnly()`, but keep returned commands safe for failed-partition re-delivery. See [Reactors](/arc/backend/typescript/chronicle/reactors/). * No `ARCCHR` analyzers exist for TypeScript. See [Code analysis](/arc/backend/typescript/chronicle/code-analysis/). Start with [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/), or run the [Library sample](/arc/backend/typescript/getting-started/library-sample/) to see a finished application. ## Related * [Chronicle TypeScript client](https://github.com/Cratis/Chronicle.TypeScript), where the SDK is developed * [CQRS without event sourcing](/arc/arc-without-event-sourcing/) * [Capability reference](/arc/backend/typescript/reference/capabilities/#persistence-and-chronicle) # Add event sourcing > Start a development Chronicle kernel, add the experimental Arc integration to your application, record an event from a command, and serve the projected read model from a query. The notes in the application from [Create an application](/arc/backend/typescript/getting-started/create-an-application/) live in memory: a restart loses them, and nothing records what happened. This guide adds [Chronicle](/chronicle/), the event-sourcing database, to that application. A command records a fact as an event, Chronicle projects the event into a read model, and a query serves the read model. When you finish, `POST /api/authors/registration/register-author` appends an `AuthorRegistered` event to a Chronicle kernel on your machine, and `GET /api/authors/listing/all-authors` returns the `Author` read model Chronicle built from it. Experimental `@cratis/arc.chronicle` is experimental and, like every package in this repository, not published to npm. Its API may change. The [capability reference](/arc/backend/typescript/reference/capabilities/#persistence-and-chronicle) lists what it covers. ## Before you start You need: * The `my-arc-app` project from [Create an application](/arc/backend/typescript/getting-started/create-an-application/), with the clone and the `arc-packages` folder next to it. * Docker with Docker Compose. * Port 35000 free on your machine. You do not need MongoDB. The Chronicle development image bundles it. ## Start a development kernel Chronicle runs as its own process, the kernel. Your application connects to it over port 35000. Create `compose.yaml` in `my-arc-app`: compose.yaml ```yaml services: chronicle: image: cratis/chronicle:latest-development ports: - "127.0.0.1:35000:35000" ``` Start it and wait until it reports healthy: ```bash docker compose up -d curl --insecure --silent --fail --retry 60 --retry-all-errors --retry-delay 1 https://localhost:35000/health ``` The second command prints `Healthy` once the kernel accepts connections, usually within 30 seconds. The development image generates a self-signed certificate, which is why the check passes `--insecure`. The port is published on `127.0.0.1` only, because the development image accepts well-known development credentials. `latest-development` follows the newest development build. For repeatable runs, pin a released development tag instead, such as `cratis/chronicle:19.26.3-development`. ## Install the Chronicle packages In the clone, pack the integration next to the other packages: ```bash cd ../Arc.TypeScript yarn workspace @cratis/arc.chronicle pack --out "$PWD/../arc-packages/arc.chronicle.tgz" cd ../my-arc-app ``` Install it together with the Chronicle SDK and RxJS: ```bash npm install ../arc-packages/arc.chronicle.tgz @cratis/chronicle@~6.49.0 rxjs@^7.8.2 ``` | Package | What it gives you | | --- | --- | | `@cratis/arc.chronicle` | Appends the events a command returns, and resolves Chronicle read models for queries and commands | | `@cratis/chronicle` | The Chronicle SDK for TypeScript, published on npm. Event types come from `@cratis/chronicle/events`, projection decorators from `@cratis/chronicle/projections` | | `rxjs` | Observables, which the integration uses for live read models | npm may report that it blocked the install script of `protobufjs`; the SDK runs without it. You do not import `reflect-metadata`. The SDK depends on it, and every SDK module that reads decorator metadata imports it. The [Library sample’s `main.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/main.ts) has no such import either. ## Register Chronicle Replace `main.ts`: main.ts ```typescript import { ArcApplication } from '@cratis/arc.core'; import '@cratis/arc.chronicle'; import { metadata } from './Features/generatedMetadata.js'; import { Notes } from './Features/Notes/Notes.js'; const builder = ArcApplication.createBuilder(); builder.useGeneratedMetadata(metadata); builder.withChronicle({ connectionString: 'chronicle://localhost:35000', eventStore: 'MyArcApp' }); builder.services.addSingleton(Notes); await builder.discover(new URL('./Features/', import.meta.url)); const app = await builder.build(); await app.run({ port: Number(process.env.PORT ?? 3000) }); ``` Importing `@cratis/arc.chronicle` adds `withChronicle` to the builder. Call it before `discover`: the integration records each discovered event type and projection and hands them to Chronicle when it connects. Classes with Arc metadata are recorded either way, but a Chronicle-only class discovered before `withChronicle` is dropped unless `activateArtifactsInScopes` is on. The application connects when a command or query first needs Chronicle, and Chronicle creates the `MyArcApp` event store then. Events and read models go to the namespace of the request’s tenant; this application resolves no tenant, so they go to the `Default` namespace. Call `withChronicle` before `add()` for Chronicle-only artifacts: without an Arc decorator, `add()` rejects them until Chronicle is registered. `chronicle://localhost:35000` without credentials uses the SDK’s development client and accepts the kernel’s self-signed certificate. That fits a local kernel only. [Registration options](/arc/backend/typescript/chronicle/registration-options/) shows how to read the connection from `appsettings.json` or environment variables, and how to pass a client you create yourself. The notes slices keep working unchanged. A command that returns no event is not affected by Chronicle. ## Record an event from a command Add an `Authors` feature next to `Notes`: ```text Features/ ├── Authors/ │ ├── AuthorId.ts │ ├── AuthorName.ts │ ├── Registration/ │ │ └── Registration.ts │ └── Listing/ │ └── Listing.ts └── Notes/ ``` The two domain values: Features/Authors/AuthorId.ts ```typescript import { ConceptAs, Guid } from '@cratis/fundamentals'; export class AuthorId extends ConceptAs { static readonly valueType = Guid; } ``` Features/Authors/AuthorName.ts ```typescript import { ConceptAs } from '@cratis/fundamentals'; export class AuthorName extends ConceptAs { static readonly valueType = String; } ``` The registration slice holds the command and the event it records: Features/Authors/Registration/Registration.ts ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, key } from '@cratis/arc.core'; import { AuthorId } from '../AuthorId.js'; import { AuthorName } from '../AuthorName.js'; @eventType() export class AuthorRegistered { @field(AuthorName) name: AuthorName; constructor(name: AuthorName = new AuthorName('')) { this.name = name; } } @command() export class RegisterAuthor { @key() @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; handle(): AuthorRegistered { return new AuthorRegistered(this.name); } } ``` `handle()` does not store anything. It returns the fact, and Arc appends it after authorization, validation, and `handle()` succeed. The `@key()` field names the event source, so each author gets their own stream. The key is not a property of the event; Chronicle keeps it as the event source ID. `@eventType()` uses the class name, `AuthorRegistered`, as the stored event type. [Returning events](/arc/backend/typescript/chronicle/commands/) covers returning several events, or events beside a response. ## Project a read model and query it The listing slice declares what an author looks like to a reader, and how events build it: Features/Authors/Listing/Listing.ts ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel } from '@cratis/arc.core'; import { ChronicleReadModels } from '@cratis/arc.chronicle'; import { fromEvent } from '@cratis/chronicle/projections'; import { AuthorRegistered } from '../Registration/Registration.js'; import { AuthorId } from '../AuthorId.js'; import { AuthorName } from '../AuthorName.js'; @readModel() @fromEvent(AuthorRegistered) export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @query() static allAuthors(models: ChronicleReadModels): Promise { return models.getAll(Author); } } ``` `@fromEvent(AuthorRegistered)` is a model-bound projection. Chronicle copies the event’s `name` onto `Author.name`, and the event source ID becomes `id`. Chronicle runs the projection inside the kernel and stores the result. `ChronicleReadModels` reads the stored read models for the current tenant; Arc provides it once Chronicle is registered. [Read models](/arc/backend/typescript/chronicle/read-models/) covers lookups by ID, live queries, and consistency. ## Regenerate and run `handle()` and the query changed the artifacts, so regenerate the metadata: ```bash npm run generate ``` It prints `Generated 6 changed file(s)`. If the watch loop from [Create an application](/arc/backend/typescript/getting-started/create-an-application/#run-the-development-loop) is running, it has already done this. Then start the server, or let `tsx` restart it: ```bash npm run dev ``` ## Check it with curl Register an author: ```bash curl -X POST http://127.0.0.1:3000/api/authors/registration/register-author \ -H 'content-type: application/json' \ -d '{"id":"6f1b8d2a-3c4e-4f5a-9b6c-7d8e9f0a1b2c","name":"Octavia E. Butler"}' ``` The answer is HTTP 200 with `"isSuccess":true` and no `response`. Arc answers success only after Chronicle has stored the event; if the append fails, the command fails. Read the authors: ```bash curl http://127.0.0.1:3000/api/authors/listing/all-authors ``` The answer’s `data` is `[{"id":"6f1b8d2a-3c4e-4f5a-9b6c-7d8e9f0a1b2c","name":"Octavia E. Butler"}]`. If `data` is `[]`, send the query again. Storing the event and projecting it are separate steps. The command answers after the first; the kernel runs the projection afterwards, usually within a second or two. A query sent in that gap reads the read model as it was before the event. [Wait for the projection](#wait-for-the-projection) shows how to handle that gap in an application. To see the event itself, browse the `MyArcApp` event store in the [Workbench](/chronicle/workbench/) at . With the [Cratis CLI](/cli/) installed, this prints the stored events: ```bash cratis chronicle events get --server chronicle://localhost:35000 --event-store MyArcApp -o json ``` It lists one `AuthorRegistered` event, with the author’s ID as `eventSourceId` and `{"name":"Octavia E. Butler"}` as `content`. Its `correlationId` matches the one in the command’s answer. ## Wait for the projection A client that registers an author and then asks for the list can run into the gap above. Choose how to handle it by what the client needs: * **Show what is there, and update it.** A screen that lists authors can subscribe to an observable query, which pushes a new list whenever the projection changes. [Read models](/arc/backend/typescript/chronicle/read-models/) shows the query, and [Subscribe to an observable query](/arc/backend/typescript/queries/subscribing-to-observable-queries/) the client side. * **Ask again.** A script or test that needs the new author can repeat the query until it appears, with a limit on how long it tries. * **Make the command wait**, as described next, when the caller must see its own write in the very next query. ### Make the command wait With `completionTimeoutMs`, Arc waits after each append until Chronicle reports that its observers have processed the event, and only then answers: main.ts (excerpt) ```typescript builder.withChronicle({ connectionString: 'chronicle://localhost:35000', eventStore: 'MyArcApp', completionTimeoutMs: 10000 }); ``` A query sent after the command’s answer then sees the new author. Know two things before you turn it on: * If an observer fails or the wait times out, the command answers HTTP 500 although the event is already stored. Do not retry such a command blindly. * Chronicle currently waits for **every** observer on the event log, including projections and reactors that do not handle the appended event type. As soon as the application has a projection that `AuthorRegistered` does not feed, `RegisterAuthor` waits the full ten seconds and answers HTTP 500, even though the event was appended and projected. This is [Cratis/Chronicle#4132](https://github.com/Cratis/Chronicle/issues/4132). Until it is fixed, use the wait only in an application where every observer handles every event you append. [Choose Chronicle read consistency](/arc/backend/typescript/queries/read-consistency/) compares these options with on-demand reads of passive projections. ## Stop the kernel ```bash docker compose down --volumes ``` This stops and removes the kernel container and the volumes the image created for its bundled MongoDB, and with them every event and read model. Without `--volumes`, those volumes stay behind unused and take up disk space. `docker compose stop` stops the kernel and keeps the data for the next `docker compose start`. ## If something goes wrong * **The health check never prints `Healthy`.** Run `docker compose logs chronicle` to see why the kernel did not start. When a new `latest-development` build fails to start, pin the previous released development tag in `compose.yaml`. * **A command or query that needs Chronicle does not answer.** The kernel is not running or not reachable at `localhost:35000`. The server starts without it, and the SDK keeps trying to connect, so the request waits instead of failing. It completes once the kernel is healthy. Run `docker compose ps` and the health check. * **The server stops with `Chronicle requires eventStore and exactly one of connectionString or client`.** `withChronicle` got no event store, no connection, or both a connection string and a client. See [Registration options](/arc/backend/typescript/chronicle/registration-options/). * **The command answers HTTP 400 with a `constraintViolation` or `concurrencyViolation` reason.** Chronicle rejected the append, and nothing was stored. See [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/). * **`register-author` and `all-authors` answer HTTP 500 with `An unexpected error occurred`.** Check that event types and projections are exported beneath the discovery root. For Chronicle-only artifacts passed to `add()`, call `withChronicle` before `add()`. ## Next steps * [Returning events](/arc/backend/typescript/chronicle/commands/): return several events, or events beside a response. * [Read models](/arc/backend/typescript/chronicle/read-models/): look up a read model by ID, and serve live lists. * [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/): decide from the state projected for the command’s key. * [Testing Chronicle commands](/arc/backend/typescript/testing/chronicle/): assert the events a command returns, without a kernel. * [The Cratis package](/arc/backend/typescript/chronicle/cratis-package/): register Arc and Chronicle with one call. * [Explore the Library sample](/arc/backend/typescript/getting-started/library-sample/): the same author slices with books, a uniqueness constraint, and a React frontend. # Aggregates > Decide from one event source's full history with a keyed aggregate root that Arc rehydrates from Chronicle and whose applied events join the command's batch. An order may hold at most 100 items. To enforce that, the command needs the order’s current total, and it needs it to be exact: a read model that lags one event behind could let the 101st item through. An **aggregate** replays the order’s own events into memory, checks the rule, and applies the new event. Because it knows which revision it replayed, a concurrent change rejects the append instead of breaking the rule. The API is experimental and part of the optional Chronicle integration. Ordinary Arc commands do not need an event store. ## Aggregate or read model | | Aggregate | Read model in a command | | --- | --- | --- | | State comes from | Replaying the event source’s events on every command | A projection Chronicle stored earlier | | Freshness | Every stored event of the handled types | Whatever the projection has processed | | Concurrency | The append is rejected when the stream moved after the replay | None; the read model does not lock anything | | Cost | Grows with the number of events in the stream | One lookup | | Use it when | A rule depends on exact history of one event source | The command needs context, or a rule can tolerate lag | A command can take both. See [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/). ## How Arc wires it 1. You define a class that extends `AggregateRoot` and registers a handler per event type. 2. A command binds it with `@inject(commandAggregate(Order))`. 3. Before `handle()` runs, Arc loads the events for the command’s key, replays them through the handlers, and records the tail it read. 4. `handle()` calls methods on the aggregate, which `apply()` new events. 5. When the command succeeds, the applied events join the command’s [batch](/arc/backend/typescript/chronicle/commands/transactional-commands/), with the recorded tail as the expected revision. ## Topics | Topic | Description | | --- | --- | | [Defining an aggregate root](/arc/backend/typescript/chronicle/aggregates/defining-an-aggregate-root/) | Handlers, state, rules, and what the TypeScript aggregate does not have | | [Injecting into commands](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/) | Binding, identity and routing, commit, concurrency, and boundaries | # Defining an aggregate root > Write a Chronicle aggregate root in TypeScript, register event handlers by event class, keep replay free of side effects, and reject invalid changes. An aggregate root holds the state one event source’s history implies, and the methods that change it. You write the rules once, on the aggregate, and every command that changes an order goes through them. ## Define the events and the aggregate The aggregate belongs to the slice whose command uses it. Put it in that slice file, next to the event it applies; [Injecting into commands](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/) adds the command to the same file: Features/Orders/Adding/Adding.ts ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { AggregateRoot } from '@cratis/arc.chronicle'; @eventType('ItemAdded') export class ItemAdded { @field(String) productId: string; @field(Number) quantity: number; constructor(productId: string, quantity: number) { this.productId = productId; this.quantity = quantity; } } export class OrderLimitExceeded extends Error { constructor() { super('The order total must not exceed 100'); } } export class Order extends AggregateRoot { quantity = 0; constructor() { super(); this.on(ItemAdded, event => { this.quantity += event.quantity; }); } canAdd(quantity: number): boolean { return Number.isSafeInteger(quantity) && quantity > 0 && quantity <= 100 - this.quantity; } addItem(productId: string, quantity: number): void { if (!this.canAdd(quantity)) throw new OrderLimitExceeded(); this.apply(new ItemAdded(productId, quantity)); } } ``` `Order` keeps one piece of state, the total quantity. `canAdd` answers the rule against that state, `addItem` guards it, and `apply()` records the change. The explicit `'ItemAdded'` ID keeps the stored event type stable even if a bundler renames the class; without it, the SDK uses the class name. When a second slice needs the same aggregate, for example `Features/Orders/Removing/` with a `RemoveItemFromOrder` command, move `Order` and the events it handles one level up, to `Features/Orders/Order.ts`, and import them from both slice files. That is where [vertical slices](/arc/backend/typescript/vertical-slices/) keep what several slices share. ## Register handlers by event class `this.on(EventClass, handler)` registers the handler for one event type. The match is by class, not by method name, and each event type can have one handler; registering a second throws. The handler runs in two situations: * **Replay.** When the aggregate is loaded, Arc fetches the stored events of the handled types and passes each one in, rebuilt as an instance of its class. * **Apply.** `apply(event)` runs the handler immediately, so the next rule in the same command already sees the new state. Keep handlers to state changes. A handler that sends an email or calls a service does so again for every old event on every load. The handler takes an optional second `EventContext` argument during replay, when you need the stored event’s sequence number or occurred time. During `apply()` there is no context, so treat it as possibly `undefined`. Arc loads only the event types the aggregate handles. An aggregate with no handlers can still apply events; it replays nothing. ## Reject a change The aggregate has no failure list. A command rejects a change the usual Arc way: `handle()` asks the aggregate, and returns `rejected(...)` when the rule says no. The caller gets a 400 with your message, and nothing the aggregate applied is appended. [Injecting into commands](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/#reject-from-handle) shows the command. The throw in `addItem` is a guard for a caller that skipped the question. It fails the command with an exception, which the caller sees as a 500, and nothing is appended either. Throw a class of your own, like `OrderLimitExceeded`, so the log names the domain problem. For input you can check without history, such as a positive quantity, a [command validator](/arc/backend/typescript/commands/command-validation/) answers with a 400 before the aggregate is even loaded. ## Know what isNew means The protected `isNew` property is `true` when the aggregate’s route had no events at all when it loaded, including events it does not handle. Use it for “create once” rules: ```typescript start(): void { if (!this.isNew) throw new Error('The order has already started'); this.apply(new OrderStarted()); } ``` This method fragment assumes an `OrderStarted` event type registered with `this.on(...)` or appended without a handler. ## What it does not have Arc on .NET’s aggregate has `Failed(...)` for collecting rule failures, `OnActivate`, and a factory for loading an aggregate by an ID other than the command key. The TypeScript aggregate has none of these. Reject through a thrown error or Arc validation, and load one event source per command key. Next, [inject the aggregate into a command](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/). # Injecting an aggregate into a command > Bind a rehydrated aggregate to a command's key with commandAggregate, reject or commit its changes, and know how identity, routing, concurrency, and the batch apply. With the aggregate defined, a command asks Arc for it by type. Arc loads it for the command’s key, replays its history, and hands it to `handle()`. Whatever the aggregate applies is appended when the command succeeds. ## Bind the aggregate Add the command to the slice file that holds `Order` and `ItemAdded`, and extend its imports: Features/Orders/Adding/Adding.ts (excerpt) ```typescript import { command, inject, key, rejected, validation } from '@cratis/arc.core'; import { AggregateRoot, commandAggregate } from '@cratis/arc.chronicle'; // ItemAdded, OrderLimitExceeded, and Order as defined before. @command() export class AddItemToOrder { @field(String) @key() id = ''; @field(String) productId = ''; @field(Number) quantity = 0; @inject(commandAggregate(Order)) handle(order: Order) { if (!order.canAdd(this.quantity)) { return rejected(validation('The order total must not exceed 100', ['quantity'])); } order.addItem(this.productId, this.quantity); } } ``` Register the command and the event type: main.ts ```typescript import { ArcApplication } from '@cratis/arc.core'; import '@cratis/arc.chronicle'; import { metadata } from './Features/generatedMetadata.js'; const builder = ArcApplication.createBuilder(); builder.withChronicle({ eventStore: 'Orders', connectionString: 'chronicle://localhost:35000' }); builder.useGeneratedMetadata(metadata); await builder.discover(new URL('./Features/', import.meta.url)); const application = await builder.build(); await application.run(); ``` `Order` and `ItemAdded` come from [Defining an aggregate root](/arc/backend/typescript/chronicle/aggregates/defining-an-aggregate-root/). Keep those exports and `AddItemToOrder` in `Features/`. Generate `Features/generatedMetadata.ts` and client proxies with the [proxy generator](/arc/backend/typescript/proxy-generation/getting-started/) before running the application. Point `--artifacts` at `Features/` and `--metadata` at `Features/generatedMetadata.ts`, using absolute paths as in the linked generator script. `discover()` registers the exported command and event type after `withChronicle`. The aggregate class itself is not registered; `commandAggregate(Order)` is enough. The development connection string needs a running local Chronicle kernel; [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/) starts one, and [Registration options](/arc/backend/typescript/chronicle/registration-options/) covers other environments. Executing `AddItemToOrder` for an empty order appends one `ItemAdded`. The next execution for the same `id` replays that event, so `quantity` starts from the stored total, and the rule is checked against it. ## Reject from handle() `handle()` returns `rejected(validation(...))` when the aggregate says no. The caller gets a 400 with the message on the `quantity` member, and the command appends nothing, including anything the aggregate applied earlier in the same `handle()`. See [Command outcomes](/arc/backend/typescript/commands/command-outcomes/). ## Commit `apply()` enrolls the event in the command’s batch. You do not have to return anything: the command above returns nothing when it succeeds, and the event is still appended. That also holds when `handle()` calls `order.commit()` without returning it, or applies more events after calling it. You may `return order.commit()` to make the commit visible. Do **not** also return the same event yourself; it would be appended twice, and Arc rejects a commit result whose events were already staged. ## Which events are loaded The aggregate belongs to the command’s key: the `@key()` field, `getKey()`, or `getEventSourceId()`. A command without a key fails with the exception `A command key is required for Order` before `handle()` runs. Loading uses the same route the command’s returned events use: the current tenant’s namespace, the command’s `@eventSourceType`, `@eventStreamType`, and its stream ID from `getEventStreamId()` or `@eventStreamId`. Arc reads the events of the types the aggregate handles, in order, and replays them. See [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/). An aggregate can instead declare an [event source definition](/arc/backend/typescript/chronicle/commands/event-source-definitions/), which guards and rehydrates only that source and stream. Arc loads each aggregate type once per command. A second parameter of the same type receives the same instance. For a second aggregate **type** on the same key, bind another `commandAggregate(Type)`. There is no way to load an aggregate for a different ID; the key decides. ## Concurrency When the aggregate loads, Arc records the tail of its route, counting every event on the route, handled or not. The events it applies are appended with that tail as an exact [concurrency scope](/arc/backend/typescript/chronicle/commands/concurrency/). If anything is appended to that route between load and commit, the batch is rejected with a `concurrencyViolation` 400, and nothing is appended. An event the aggregate does not handle moves the tail too, but it cannot block the aggregate permanently: the next load reads the new tail. ## Boundaries * Applied events and returned events join the same [batch](/arc/backend/typescript/chronicle/commands/transactional-commands/). Nested commands share it, so an aggregate changed in a nested command commits with the outer command. * An immediate SDK append inside `handle()` is outside the batch. It is stored even when the aggregate’s batch is later rejected. * The batch covers one event log. It is not a transaction over other stores. * The [command subject](/arc/backend/typescript/chronicle/commands/subject/) applies to the aggregate’s events as it does to returned events. * `readModelForValidation` and `commandReadModel` read the stored projection, not the aggregate’s replayed state. They can disagree while the projection catches up. ## Related * [Aggregates](/arc/backend/typescript/chronicle/aggregates/) * [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/) * [Testing Chronicle commands](/arc/backend/typescript/testing/chronicle/) # Chronicle code analysis > Arc on .NET's Chronicle diagnostics mapped to TypeScript lint rules, runtime checks, and inapplicable C# patterns. As of .NET v22.23.0, Arc shipped ten Chronicle analyzers (`ARCCHR0001`–`ARCCHR0010`); this mapping covers that set. Five have bounded TypeScript ESLint analogs in `@cratis/eslint-plugin-arc-core`. Configure the plugin as described in [Code analysis](/arc/backend/typescript/code-analysis/). Both presets enable `arcchr0003`, `arcchr0007`, and `arcchr0009`. `arcchr0006` and `arcchr0010` need type information and are enabled by `recommended-type-checked`. ESLint reports enabled rules as errors, including analogs of .NET warnings. ## ARCCHR mapping | .NET diagnostic | .NET default | TypeScript status | | --- | --- | --- | | ARCCHR0001, aggregate handler signature | Error | N/A | | ARCCHR0002, ambiguous command identity | Warning | N/A / runtime | | [ARCCHR0003](/arc/backend/typescript/code-analysis/arcchr0003/), reactor reaches default log | Warning | ESLint analog | | ARCCHR0004, redundant `[EventType]` id | Warning | N/A | | ARCCHR0005, Chronicle used but not configured | Warning | Partly caught at runtime | | [ARCCHR0006](/arc/backend/typescript/code-analysis/arcchr0006/), reactor returns a command without a replay decision | Warning | Type-checked ESLint analog | | [ARCCHR0007](/arc/backend/typescript/code-analysis/arcchr0007/), command injects event log | Warning | ESLint analog | | ARCCHR0008, data annotations `[Key]` | Warning | N/A | | [ARCCHR0009](/arc/backend/typescript/code-analysis/arcchr0009/), secret-looking command property | Warning | ESLint analog for names not masked at runtime | | [ARCCHR0010](/arc/backend/typescript/code-analysis/arcchr0010/), raw GUID response | Warning | Type-checked ESLint analog | * **ARCCHR0001:** `this.on(EventClass, handler)` registers a typed callback; duplicate handlers throw. No `On` method-signature convention exists. * **ARCCHR0002:** `getEventSourceId()`, `getKey()`, or one `@key()` determines the key. A second `@key()` throws when the class is defined. * **ARCCHR0003:** The rule finds direct `eventLog.append` or `appendMany` calls through a `this` field initialized from `this.client.getEventStore` or `this.runtime.getStore`. It cannot prove the field’s store belongs to the reactor; it ignores fields initialized from another client. Return events instead. * **ARCCHR0004:** An explicit TypeScript SDK ID equal to the class name stabilizes persisted type identity across minification or renaming. Removing it changes guarantees. * **ARCCHR0005:** `commandReadModel(Type)` without an owner fails `build()`. A returned event without `withChronicle` becomes an ordinary response and is not caught. A per-file ESLint rule cannot prove registration in a separate host module. * **ARCCHR0006:** TypeScript reactors return Arc commands rather than calling .NET’s `ICommandPipeline.Execute`. The type-checked rule follows same-class helper calls back to live handlers and warns when none has a replay decision. Class- or handler-level `@onceOnly()` and an `@replay()` handler for the same event silence it. Dispatch matches the camel-cased event class name, not the parameter annotation. See [the rule’s bounds](/arc/backend/typescript/code-analysis/arcchr0006/). * **ARCCHR0007:** The rule finds direct `eventLog.append` or `appendMany` calls from a command’s `handle()` or `provide()` through an artifact store or a `@inject(ChronicleReadModels | ChronicleRuntime)` parameter, including local and inline `getStore()` calls. Indirect appends remain a review concern. * **ARCCHR0008:** TypeScript has only Arc’s `@key()`; there is no competing data-annotations decorator. * **ARCCHR0009:** Arc withholds names containing `password`, `secret`, `token`, `credential`, or `apiKey`, and fields marked `@notAudited()` or `@pii()`. The rule checks the remaining .NET secret words, including Passphrase, PrivateKey, and AuthorizationHeader, without falsely claiming masked names are written to causation. * **ARCCHR0010:** A keyless command returning `tuple(Guid.create(), new DecoratedEvent())` returns an ordinary response instead of selecting the event source. The type-checked rule also recognizes Guid variables and `Guid.parse(...)`; it does not infer plain strings, indirect event factories, inherited event-type decorators, or other tuple shapes. As of .NET v22.23.0, there was no ARCCHR analyzer for nullable event properties or past-tense event names; this mapping does not add either rule. ## What to check in review * Appends through helper methods or stores not held directly by a command or reactor can bypass the return-value pipeline. * Commands executed manually inside a reactor (for example through an Arc server) are not detected. They still need a replay decision; `@onceOnly()` skips replay but does not prevent failed-partition re-delivery. See [Returning commands from a reactor](/arc/backend/typescript/chronicle/reactors/command-side-effects/#when-a-command-fails). * An app that returns events must install `withChronicle`; check the host, not just the artifact file. * A tuple carrying an ordinary string instead of `eventSourceIdResponse(id)` does not select an event source; see [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/#return-the-id-to-the-caller). ## Related * [Code analysis](/arc/backend/typescript/code-analysis/) * [Chronicle](/arc/backend/typescript/chronicle/) # Returning events > Return a Chronicle event, a batch of events, or events next to a response from a model-bound command, and know which returned values are appended. A command decides what happened; the integration appends it. Return the event from `handle()`, and Arc appends it only after authorization, validation, and `provide()` have passed. ## Return an event This excerpt is from the compiled [kernel suite](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Chronicle/Integration/LiveArtifacts.ts), in the shape you would write it in your application: ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, key } from '@cratis/arc.core'; @eventType() export class TaskCreated { @field(String) title: string; constructor(title: string) { this.title = title; } } @command() export class CreateTask { @field(String) @key() id = ''; @field(String) title = ''; handle(): TaskCreated { return new TaskCreated(this.title); } } ``` With Chronicle [registered](/arc/backend/typescript/chronicle/add-event-sourcing/), `POST /api/create-task` appends one `TaskCreated` to the event source named by the command’s `@key()` field, in the tenant’s namespace. The command result carries no `response`, because the event was consumed on the server. ## What is appended | `handle()` returns | Result | | --- | --- | | A registered event | Appended to the command’s event source | | An array of registered events | Appended in one SDK batch | | An array of ordinary objects | An ordinary response, not events | | `tuple(event, 'message')` | The event is appended to the command key, and `'message'` is the response | | `tuple(eventSourceIdResponse(id), event)` | The event is appended to `id`, and `id` is returned to the caller | | `eventForEventSourceId({ eventSourceId, event, ... })` | Appended to that event source with explicit routing | | `eventsWithConcurrencyScopes(events, scopes)` | Appended with exact concurrency scopes; see [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/) | The integration is a [response value handler](/arc/backend/typescript/commands/response-value-handlers/): it recognizes registered event instances and branded values, and everything else keeps its ordinary meaning. A command can return Chronicle events together with [command operations](/arc/backend/typescript/commands/operations/) in a `tuple(...)`: the operations run first, and the event batch decides whether they are compensated; see [Events and command operations](/arc/backend/typescript/chronicle/commands/transactional-commands/#events-and-command-operations). An event or operation can’t be the command’s client response. The event source, routing decorators, and explicit targets are covered in [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/). Everything else an appended event carries, such as its subject, causation, and correlation ID, is listed in [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/). ## Low-level definitions The older `defineChronicleCommand` remains. It takes a client, an event store, a tenant namespace resolver, and `produce()` returning `{ events, response }`, and appends immediately with its own rejection mapping. Use the model-bound path for new commands. Neither route makes the SDK’s observer completion synchronous with an append acknowledgment. ## Related * [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/) * [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/) * [Testing Chronicle commands](/arc/backend/typescript/testing/chronicle/) # Causation and auditing > What the Chronicle integration records in the permanent causation chain for each command, which values it leaves out, and how @notAudited and @pii keep a secret or personal value out of it. Six months from now someone asks why a purchase order exists. The event says what happened. Its **causation chain** says how it came about: which command produced it, and what that command was asked to do. The integration writes that record for you on every event a command returns. The chain is stored with the event in the event log, and the event log is immutable. A value recorded there stays for as long as the event does, and every replay reads it. Changing your code later does not remove it. Decide what belongs in a permanent audit record before you add a field to a command. ## What is recorded For this command: ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, key } from '@cratis/arc.core'; @eventType() export class PurchaseOrderRaised { @field(String) supplier: string; @field(Number) amount: number; constructor(supplier = '', amount = 0) { this.supplier = supplier; this.amount = amount; } } @command() export class RaisePurchaseOrder { @field(String) @key() orderId = ''; @field(String) supplier = ''; @field(Number) amount = 0; handle(): PurchaseOrderRaised { return new PurchaseOrderRaised(this.supplier, this.amount); } } ``` a request with `{ "orderId": "po-26", "supplier": "ACME", "amount": 1234.56 }` adds an `Arc.Command` entry to the chain of the appended event: | Property | Value | | --- | --- | | `Command` | `RaisePurchaseOrder` | | `Value.orderId` | `po-26` | | `Value.supplier` | `ACME` | | `Value.amount` | `1234.56` | The rules behind that table: * The command’s class name is always recorded, under `Command`. * String, number, boolean, bigint, and [concept](/arc/backend/typescript/concepts/) values wrapping those primitives are recorded as `Value.`, converted to a string and cut at 1,024 characters. Concepts wrapping `Guid` are recorded as the GUID string. * Fundamentals `Guid`, `DateOnly`, `TimeOnly`, and `TimeSpan` values are recorded as strings; `Date` is recorded in UTC ISO-8601 form. Arrays and other nested objects are left out. Mark sensitive concepts `@pii()` or fields `@notAudited()`; their raw values must not be written to this permanent chain. * The event also carries the correlation ID of the request and, as its identity, the signed-in principal, or Chronicle’s system identity for an anonymous caller. When commands run inside other commands, every event in the batch carries the chain of the outermost command; see [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/#causation-in-a-batch). Commands a reactor returns get a `ReactorEvent` entry for the triggering event ahead of their own; see [Returning commands from a reactor](/arc/backend/typescript/chronicle/reactors/command-side-effects/). ## Keep a value out Three things keep a field’s value off the chain. The command is still named either way: an audit trail that hides which commands ran would not be an audit trail. ### Secrets: `@notAudited()` ```typescript import { field } from '@cratis/fundamentals'; import { command, key } from '@cratis/arc.core'; import { notAudited } from '@cratis/arc.chronicle'; @command() export class ConnectAccount { @field(String) @key() id = ''; @field(String) @notAudited() confirmationCode = ''; handle(): AccountConnected { return new AccountConnected(); } } ``` This fragment assumes an `AccountConnected` event type. `@notAudited()` from `@cratis/arc.chronicle` withholds the field’s value from the causation chain. It only withholds: it does not encrypt the value or enroll it in erasure. Apply it to a public instance field; with standard decorators, a static or private field throws when the class is defined. ### Personal data: `@pii` The SDK’s `@pii()` from `@cratis/chronicle/compliance` on a command field also keeps its value off the chain. On the command class itself, it withholds every value the command has. Marking the command does not mark the event you construct from it: annotate the event’s own properties where the event stores personal data. See [Compliance](/arc/backend/typescript/chronicle/compliance/). ### Secret-looking names A field whose name contains `password`, `secret`, `token`, `credential`, or `apiKey`, in any letter case, is never recorded, marked or not. The match is on part of the name, so `tokenCount` is skipped too. Do not rely on the name: a field called `value` that holds an API key is recorded. Mark it. ## Read the chain In the [Chronicle Workbench](/chronicle/workbench/), open an event’s context and then its causation entries. The `Arc.Command` entry lists the properties in the table above. ## Related * [Returning events](/arc/backend/typescript/chronicle/commands/) * [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/) * [Code analysis](/arc/backend/typescript/chronicle/code-analysis/), for why no TypeScript rule flags an unmarked secret # Concurrency > Reject a Chronicle append when an event source moved, with routing-decorator tail checks, exact concurrency scopes, or an aggregate, and read the validation result a rejection produces. Two librarians open the same book and both mark it as lent. Without a check, both commands succeed and the book is lent twice. Optimistic concurrency makes the second append fail: Chronicle compares the stream’s current tail with the tail the command expected, and rejects the append when they differ. The integration gives you three ways to state that expectation. Choose by how the command decides. | The command decides from | Use | | --- | --- | | Nothing it read; it only needs no one else to write in between | A routing decorator with `{ concurrency: true }` | | An empty stream, such as “create once” | An exact scope with `EventSequenceNumber.beforeFirst` | | The event source’s history | An [aggregate](/arc/backend/typescript/chronicle/aggregates/), which carries the revision it replayed | For a rule across event sources, such as a unique author name, use a Chronicle constraint instead. The [Library sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Registration/Registration.ts) registers `UniqueAuthorName` beside its command. ## Check the tail with a routing decorator ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, key } from '@cratis/arc.core'; import { eventSourceType } from '@cratis/arc.chronicle'; @eventType() export class BookLent { @field(String) borrower: string; constructor(borrower = '') { this.borrower = borrower; } } @command() @eventSourceType('Book', { concurrency: true }) export class LendBook { @field(String) @key() bookId = ''; @field(String) borrower = ''; handle(): BookLent { return new BookLent(this.borrower); } } ``` `{ concurrency: true }` works the same on `@eventSourceType`, `@eventStreamType`, and `@eventStreamId`. When the command returns its events, the integration reads the tail of each event source in the batch, narrowed to the dimensions you marked, and sends that tail as the expected revision. A write that lands between that read and the append is rejected. The tail is read **after** `handle()` has run. A write that landed while `handle()` was deciding is already part of that tail, so this check does not protect a decision made from state read earlier. For a read-modify-write rule, carry the revision you read, as the next two sections do. ## Pin the revision with an exact scope ```typescript import { field } from '@cratis/fundamentals'; import { EventSequenceNumber } from '@cratis/chronicle/eventSequences'; import { command, key } from '@cratis/arc.core'; import { eventsWithConcurrencyScopes } from '@cratis/arc.chronicle'; @command() export class CreateLiveExactlyOnce { @field(String) @key() id = ''; @field(String) name = ''; handle() { return eventsWithConcurrencyScopes([new LiveCreated(this.name)], { [this.id]: { eventSourceId: true, sequenceNumber: EventSequenceNumber.beforeFirst.value } }); } } ``` This excerpt is from the [kernel suite](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Chronicle/Integration/LiveArtifacts.ts), where `LiveCreated` is an `@eventType()` class. `eventsWithConcurrencyScopes(events, scopes)` sends each scope exactly as you wrote it. `EventSequenceNumber.beforeFirst.value` is the expected tail of a stream with no events, so the command succeeds once per ID; the second call is rejected. Each scope is keyed by an event source ID and accepts: | Field | Meaning | | --- | --- | | `sequenceNumber` | The expected tail, a `bigint` | | `eventSourceId` | `true` to scope to that event source | | `eventSourceType`, `eventStreamType`, `eventStreamId` | Narrow the scope to that source type or stream | | `eventTypes` | Narrow the scope to these event types | A scope may name an event source the batch does not append to, which lets a decision about one source guard against changes to another. An exact scope replaces the routing-decorator check for the same source. The kernel requires at least one event in a batch, so **a batch with scopes and no events fails**. ## Let an aggregate carry it An aggregate records the tail of its route when it is loaded, and the events it applies are appended with that tail as an exact scope. A concurrent append on the same route between load and commit rejects the batch. See [Aggregates](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/). ## When the check fails A concurrency or constraint rejection becomes a validation result. The caller gets 400, the command result carries no response, and nothing from the batch is appended. | Rejection | `reason` | `message` | Other fields | | --- | --- | --- | --- | | Concurrency | `concurrencyViolation` | `Concurrent modification prevented the append` | `state` with `eventSourceId`, `expectedEventSequenceNumber`, and `actualEventSequenceNumber` | | Constraint | `constraintViolation` | The constraint’s message | `members` holds the constrained property in camel case when Chronicle reports one; `reasonDetail` holds the constraint ID | Both have error severity. A client can show the message or retry the command with fresh state. An unknown, incomplete, contradictory, or partial acknowledgment from Chronicle is not a rejection. The command fails with an exception, because Arc cannot tell what was stored. ## Nested commands and tests Nested commands that share a batch may each add scopes. Two different scopes for the same event source fail the command instead of silently keeping one. The in-memory [test scenario](/arc/backend/typescript/testing/chronicle/) accepts scopes but does not enforce them. Only a kernel does. ## Related * [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/) * [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/) * [Chronicle constraints](/chronicle/constraints/) # Event metadata > What every event a command returns carries besides its payload, where the integration takes each value from, and how to override or read it. An event records more than its payload. It also records which entity it belongs to, which stream within that entity, whose personal data it carries, when it happened, who caused it, and which request it was part of. You rarely want to set those by hand on every command. The integration resolves each value from the command and the request, and lets you override the ones that differ. ## What each event carries | Metadata | Taken from | Override for one event | | --- | --- | --- | | Event source ID | `getEventSourceId()`, then the `@key()` field, then a new UUID; see [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/) | `tuple(eventSourceIdResponse(id), event)` or `eventForEventSourceId({ eventSourceId })` | | Event source type | `@eventSourceType('Author')` on the command | `eventForEventSourceId({ eventSourceType })` | | Event stream type | `@eventStreamType('Onboarding')` on the command | `eventForEventSourceId({ eventStreamType })` | | Event stream ID | `getEventStreamId()` on the command, then `@eventStreamId('main')` | `eventForEventSourceId({ eventStreamId })` | | Subject | `getSubject()`, then a `@subject()` field, then `@eventSubject(...)`, then the event source ID; see [Subject](/arc/backend/typescript/chronicle/commands/subject/) | `eventForEventSourceId({ subject })` | | Tags | The SDK’s `@tag` and `@tags` on the event class | `eventForEventSourceId({ tags })` adds tags for that append | | Occurred | Set when the event is appended | `eventForEventSourceId({ occurred })` | | Correlation ID | The request’s correlation ID | None | | Caused by | The signed-in principal, or Chronicle’s system identity for an anonymous caller | None | | Causation | An `Arc.Command` entry with the command name and its values; see [Causation and auditing](/arc/backend/typescript/chronicle/commands/causation/) | None | To select a registered Chronicle event source definition instead of free-form strings, see [Event source definitions](/arc/backend/typescript/chronicle/commands/event-source-definitions/). Routing decorators come from `@cratis/arc.chronicle` and apply to every event the command returns. A value set on an `eventForEventSourceId` entry wins over the command’s default for that entry only. ## Set command-wide defaults ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, key } from '@cratis/arc.core'; import { eventSourceType, eventStreamType } from '@cratis/arc.chronicle'; @eventType() export class OnboardingStarted { @field(String) plan: string; constructor(plan = '') { this.plan = plan; } } @command() @eventSourceType('Customer') @eventStreamType('Onboarding') export class StartOnboarding { @field(String) @key() customerId = ''; @field(String) plan = ''; getEventStreamId(): string { return `onboarding-${this.plan}`; } handle(): OnboardingStarted { return new OnboardingStarted(this.plan); } } ``` `OnboardingStarted` is appended to the customer’s event source, with source type `Customer`, stream type `Onboarding`, and a stream ID computed per command. `getEventStreamId()` runs after validation, so it may read any field. When you also declare `@eventStreamId(...)`, the method wins. The same stream settings select which events an [aggregate](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/) loads, so an aggregate and the events its command returns agree on the stream. ## Override one event ```typescript import { eventForEventSourceId } from '@cratis/arc.chronicle'; handle() { return eventForEventSourceId({ eventSourceId: 'routed', event: new Registered(), eventSourceType: 'Override', subject: 'subject-1', tags: ['tag-1'] }); } ``` This excerpt is from the package’s [scenario spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Chronicle/testing/for_ChronicleCommandScenario/when_executing/with_returned_events.ts), where `Registered` is an `@eventType()` class. The entry is branded, so an ordinary response object that happens to have `event` and `eventSourceId` fields is never mistaken for an event. Return an array to mix routed entries with plain events in one batch. ## Read it back Chronicle hands the metadata back as an `EventContext`. A reactor receives it as the second handler argument; see [Reactors](/arc/backend/typescript/chronicle/reactors/). A model-bound projection can copy a context value into a read model with the SDK’s `@setFromContext`: ```typescript import { field } from '@cratis/fundamentals'; import { fromEvent, setFromContext } from '@cratis/chronicle/projections'; import { readModel } from '@cratis/arc.core'; @readModel() @fromEvent(OnboardingStarted) export class Onboarding { @field(String) id = ''; @field(String) plan = ''; @setFromContext(OnboardingStarted, 'occurred') @field(Date) startedAt!: Date; } ``` `plan` is copied from the event by matching name, and `startedAt` takes the event’s occurred time. In a test, `result.appendedEvents` on [`ChronicleCommandScenario`](/arc/backend/typescript/testing/chronicle/) exposes each appended event with its routing, subject, and tags. ## Related * [Returning events](/arc/backend/typescript/chronicle/commands/) * [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/) * [Event source definitions](/arc/backend/typescript/chronicle/commands/event-source-definitions/) * [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/), where the same routing decorators opt into tail checks # Event source definitions > Route a command's events, and an aggregate's, through a Chronicle event source definition and stream, and know what Arc validates at startup and what Chronicle enforces on append. A string such as `@eventSourceType('Account')` names where an event goes, but nothing checks that the name exists or that the stream belongs to it. A Chronicle event source definition is that check: a registered class that declares a source and its streams. A command selects one with `@eventSourceDefinition`, and each event it returns records the definition it was appended through. Source and stream are routing. You declare them on the command or the aggregate, never on an event type. This feature needs `@cratis/chronicle` 6.49.0 or later. Older SDKs keep working for string routing, and a command that selects a definition fails with a message naming the required version. ## Declare a definition and select it Declare the definition with the SDK’s decorators, then select it on the command. ```typescript import { field } from '@cratis/fundamentals'; import { ConcurrencyDimensions, eventSource, eventStream } from '@cratis/chronicle'; import { command, key } from '@cratis/arc.core'; import { eventSourceDefinition } from '@cratis/arc.chronicle'; @eventSource() @eventStream('Transactions', { concurrency: ConcurrencyDimensions.eventStreamType | ConcurrencyDimensions.eventStreamId }) export class Account {} @eventSourceDefinition(Account, 'Transactions') @command() export class Deposit { @field(String) @key() id = ''; handle(): FundsDeposited { return new FundsDeposited(); } } ``` `FundsDeposited` is an `@eventType()` class. The event is appended with source `Account` and stream type `Transactions`. Chronicle records the definition on the event, and a reactor or projection reads it from `EventContext.eventSource`. The first argument is the class, its registered name, or a function returning the class. Use the function form, `@eventSourceDefinition(() => Account, 'Transactions')`, when the definition’s module imports the command and the plain class would be read before it is defined. Referencing the class registers it with Chronicle, so you do not add it to discovery separately. A name can only be resolved against definitions that are registered, so a name Arc cannot find fails at startup. ## What fails at startup Arc checks every command that selects a definition when the application is built, without a connection, and refuses to build when: * the class is not decorated with `@eventSource()`; * the stream is not one the definition declares; * a name matches no registered definition; or * `@eventSourceType` or `@eventStreamType` on the same command contradicts the definition. Repeating the definition’s own name is allowed. An aggregate, which Arc only meets when a command uses it, is checked the first time it loads. ## Concurrency comes from the definition When a command selects a definition and sets no concurrency flags, Arc passes no scope and Chronicle derives one from the dimensions the definition or stream declares. Explicit flags win: `@eventStreamId('2026-05', { concurrency: true })` builds the same explicit scope as without a definition, using the definition’s source name, and Chronicle then derives nothing. Chronicle’s client refuses events for one event source ID that would need different automatic scopes within a single batch. Arc does not work around that; the command fails and appends nothing. Pass an explicit scope with `eventsWithConcurrencyScopes`, or return the events in separate commands. ## Override one event An event entry that names its own `eventSource` and `eventStream`, or its own raw `eventSourceType` or `eventStreamType`, takes over source and stream for that event as one unit. The command’s definition is neither merged into it nor used to rewrite it. ```typescript import { eventForEventSourceId } from '@cratis/arc.chronicle'; handle() { return eventForEventSourceId({ eventSourceId: this.id, event: new FundsPosted(), eventSource: Ledger, eventStream: 'Postings' }); } ``` A raw `eventStreamType` on an entry wins over the command’s definition the same way, and the entry is appended without a definition. The stream ID stays a separate default from `getEventStreamId()`. ## Aggregates Declare the definition on the aggregate class to guard and rehydrate only that source and stream. ```typescript @eventSourceDefinition(Account, 'Transactions') export class Wallet extends AggregateRoot { constructor() { super(); this.on(FundsDeposited, () => {}); } } ``` Loading reads the tail and the events for the declared source and stream only, the concurrency scope carries the same source and stream, and every event the aggregate applies records the definition. A command and its aggregate may each declare a definition when they agree; two that name different sources or streams fail instead of one silently winning. ## Related * [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/) * [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/) * [Defining an aggregate root](/arc/backend/typescript/chronicle/aggregates/defining-an-aggregate-root/) # Subject > Tell Chronicle whose personal data the events of a command carry, with getSubject(), a @subject() field, @eventSubject, or a routed event, and know the resolution order. An order belongs to the order’s event source, but the email address on it belongs to a customer. When that customer asks to be forgotten, Chronicle must find every event carrying their data, across every order. The **subject** is how it finds them: Chronicle records a compliance subject on each event, and keys personal-data handling to it. By default the subject is the event source ID, which is right when the entity is the person. Set it on the command when the data belongs to someone else. ## Compute it on the command ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, key } from '@cratis/arc.core'; @eventType() export class OrderPlaced { @field(String) customerId: string; @field(Number) amount: number; constructor(customerId = '', amount = 0) { this.customerId = customerId; this.amount = amount; } } @command() export class PlaceOrder { @field(String) @key() orderId = ''; @field(String) customerId = ''; @field(Number) amount = 0; getSubject(): string { return this.customerId; } handle(): OrderPlaced { return new OrderPlaced(this.customerId, this.amount); } } ``` `OrderPlaced` is appended to the order, and its subject is the customer. `getSubject()` runs after `handle()`, when the returned events are prepared, so it can read any field. Return a string. ## Mark a field When a field already holds the subject, mark it with the SDK’s `@subject()` from `@cratis/chronicle/compliance`: ```typescript import { field } from '@cratis/fundamentals'; import { subject } from '@cratis/chronicle/compliance'; import { command, key } from '@cratis/arc.core'; @command() export class RegisterCustomer { @field(String) @key() customerId = ''; @field(String) @subject() personId = ''; @field(String) email = ''; handle(): CustomerRegistered { return new CustomerRegistered(this.email); } } ``` This command fragment assumes a `CustomerRegistered` event type in your application. A `@subject()` field can hold a string, Fundamentals `Guid`, or a concept wrapping a primitive or `Guid`; Arc converts it to the string Chronicle stores. Unsupported objects fail the command rather than silently falling back to the event source ID. ## Set a fixed subject `@eventSubject('subject')` from `@cratis/arc.chronicle` sets the same subject for every event the command returns. It suits a subject that does not vary per request, such as a system actor. ## Resolution order For each event a command returns, the integration takes the first of: 1. the `subject` on an [`eventForEventSourceId`](/arc/backend/typescript/chronicle/commands/event-metadata/#override-one-event) entry; 2. `getSubject()` on the command; 3. a `@subject()` field holding a string, `Guid`, or supported concept; 4. `@eventSubject(...)` on the command; 5. the event’s event source ID. Events an [aggregate](/arc/backend/typescript/chronicle/aggregates/) applies go through the same order, because the command’s batch prepares them the same way. The subject never comes from the signed-in user. Authentication tells you who made the request; the subject says whose data the event holds, and the two often differ. ## What the subject does not do Setting a subject does not mark anything as personal data. Values are marked with the SDK’s `@pii` decorator, and Chronicle’s compliance handling keys them to the subject; the [Chronicle compliance](/chronicle/compliance/) documentation covers that side. Read models read through Chronicle arrive decrypted, and Arc releases encrypted values at its query edge only for protected read models read directly from MongoDB into their exact class. See [Compliance](/arc/backend/typescript/chronicle/compliance/) for what the integration does and does not handle. ## Related * [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/) * [Compliance](/arc/backend/typescript/chronicle/compliance/) * [Chronicle compliance](/chronicle/compliance/) # Transactional commands > How returned events from a command and its nested commands join one event-log batch, when the batch is sent or discarded, how it interacts with command operations, and what lies outside it. Registering a member runs two commands: one creates the member, the other opens their first loan card. If the second is rejected, you do not want a member without a card. The integration collects the events every command in the chain returns and sends them to Chronicle as **one batch**, after the outermost command has succeeded. Either all of them are appended, or none. This is a batch on one event log, sent with the SDK’s `appendMany`. It is not a transaction over other databases, HTTP calls, or services. ## Choose how to append | How | Joins the batch | What the command result means | | --- | --- | --- | | Return events from `handle()` | Yes | The final verdict, after the batch is sent | | Return `eventsWithConcurrencyScopes(...)` or `eventForEventSourceId(...)` | Yes | Same | | `aggregate.apply(...)` on a [command aggregate](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/) | Yes, whether or not you return `aggregate.commit()` | Same | | An SDK `eventLog.append(...)` inside `handle()` | No; it is appended immediately | Nothing about that append | | A low-level `defineChronicleCommand` | No; it appends on its own | Its own result | Return events. It is the path that keeps the command’s verdict and the stored events in step. ## One batch per outer command Returned events from nested Arc commands that share the tenant, correlation ID, and event store join the outer command’s batch. Arc sends the batch after the outer command has run and its result is a success. * If the outer command or any nested command fails, none of the staged events are sent. * A nested command’s success is provisional: its events are staged, not stored. Only the outer result carries the final verdict. * If the outer command ignores a failed nested command, even one that staged no events, the outer command still fails, with the message `Nested Chronicle command failed; staged events were discarded`. * A nested command with a different tenant, correlation ID, or event store fails instead of joining. * A command that runs after the outer command has completed, such as one started from a timer, starts its own batch. Authorization, Arc validation, and a rejecting `provide()` all run before anything is staged, so a rejected command never reaches Chronicle. ## When the batch is sent The outer command finishes, then Arc sends the batch and turns Chronicle’s answer into the command result: | Chronicle answers | Command result | | --- | --- | | Every event accepted | Success, with the response `handle()` returned | | A constraint or concurrency rejection | 400 with the validation results, and no response; see [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/#when-the-check-fails) | | An error, a partial or incomplete acknowledgment, or no answer | Failure with an exception message; some events may have been stored | A request aborted before the batch is sent sends nothing. ## Causation in a batch The SDK stamps one causation chain per batch. Every event in it, including events from nested commands, carries the outer command’s causation entry, not the entry of the command that returned it. See [Causation and auditing](/arc/backend/typescript/chronicle/commands/causation/). ## Events and command operations A command may return Chronicle events together with [command operations](/arc/backend/typescript/commands/operations/), in a `tuple(...)`. The batch then decides whether the operations are compensated: 1. Arc runs the operations in order. 2. It sends the batch. 3. On a constraint or concurrency rejection, nothing was appended, so Arc compensates the operations in reverse order. 4. On an unknown, partial, or incomplete outcome, Arc reports the command as failed but **does not compensate**. Events may already be stored, and they cannot be undone. Operations therefore need compensation only for a known rejection. This is still not a distributed transaction: an external system that accepted the operation stays changed until compensation runs, and a crash in between is not recovered. ## What is outside the batch These happen outside the batch and cannot be discarded with it: * an immediate SDK append inside `handle()`; * a low-level `defineChronicleCommand`; * anything a handler does directly to another store or service. An immediate append can already be stored when the batch is later rejected, and Arc cannot see it. Arc **still compensates the command operations** in that case. Do not combine immediate appends with compensated operations when the compensation assumes no events were stored. ## Things to know * **Reads do not see staged events.** A read model or an event-log read inside the chain sees stored events only, not the batch being built. * **Several reactor commands are several batches.** Each command a reactor returns runs on its own; see [Returning commands from a reactor](/arc/backend/typescript/chronicle/reactors/command-side-effects/#return-several-commands). * **Atomic is not the same as current.** The batch is all-or-nothing, but it does not check that the decision was made on the latest state. See [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/). ## Related * [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/) * [Returning events](/arc/backend/typescript/chronicle/commands/) * [Command execution scopes](/arc/backend/typescript/commands/command-execution-scopes/) # Compliance > What the experimental Chronicle integration does for personal data, subjects, and audit exclusion in TypeScript, and which parts of Arc on .NET's compliance support it does not have. Events are immutable, yet a person can ask for their personal data to be erased. Chronicle resolves that tension by keying personal data to a **subject** and managing encryption keys per subject; destroying the key makes the data unreadable while the events stay. The [Chronicle compliance guide](/chronicle/compliance/) explains that side. An Arc application meets compliance at two points: when a command appends events, and when a query serves the read models built from them. Chronicle (the kernel, or the SDK for reducer read models) releases read models on Chronicle reads. At its query edge, Arc releases protected models decoded into their exact read-model class from a direct collection read. It also releases raw MongoDB documents that you have explicitly typed as a protected read model. ## What the integration does | Concern | In Arc for TypeScript | | --- | --- | | Record the subject on appended events | Supported. `getSubject()`, a `@subject()` field, `@eventSubject(...)`, or a routed event’s `subject`, falling back to the event source ID. See [Subject](/arc/backend/typescript/chronicle/commands/subject/). | | Keep command values out of the causation chain | Supported. `@notAudited()`, the SDK’s `@pii()` on a command field or class, and secret-looking field names. See [Causation and auditing](/arc/backend/typescript/chronicle/commands/causation/#keep-a-value-out). | | Mark event or read-model data as personal | Done with the SDK’s `@pii()` from `@cratis/chronicle/compliance`. Arc passes your event classes to the SDK unchanged. | | Release encrypted values when a query serves a read model | Kernel reads through Chronicle already return plaintext. Arc releases a protected Chronicle read model read directly from its materialized MongoDB collection, including items in snapshots, arrays, pages, and observable emissions. This covers decoded instances and raw `MongoReadModels` documents typed with `readModel`. | | Release encrypted values in a command’s read model | The Chronicle kernel releases read models before `commandReadModel(Type)` or a Chronicle validator read-model lookup returns them; null remains null. | | Erase a subject’s key | Not part of Arc. Use Chronicle’s own tooling or the SDK’s PII manager. | ## Where release happens Mark the **read-model property** `@pii()` as well as the event property when projected personal data must be encrypted at rest. Marking only the event protects the event log but leaves the projected read-model field in plaintext. Chronicle releases values on reads through Chronicle, including `ChronicleReadModels` snapshots, observations, watches, and command injection. Arc does not release those instances again. The exception is a reducer model whose protection sits only in nested values or `@encrypted()` fields: the TypeScript SDK does not release those, so Arc does not trust them. Returned directly, in an array or in a query page, Arc releases them at the query edge; nested inside another shape, they fail the query. The kernel releases projection watch changes, removals included, but the SDK does not release a removed reducer change: for a protected reducer model, `ChronicleReadModels` replaces a removed change’s model with an empty instance of the type rather than serve that unreleased payload. Instances obtained by calling the SDK directly through `ChronicleReadModels.getStore()`, or clones of released instances, may receive an extra, harmless release at the Arc query edge. ### Release at the Arc query edge Arc registers a scoped interceptor for Chronicle read-model classes with compliance or `@encrypted()` security metadata anywhere in their schema, including nested objects and array items. When a query returns an instance decoded **into the exact read-model class** from the materialized MongoDB collection (for example, through `@cratis/arc.mongodb`’s `MongoCollection`), Arc calls `store.readModels.release(Type, instance)` on the tenant store before wire encoding, for snapshots and observable deliveries. A release error fails the query or emission rather than serving ciphertext. Reducer models follow the same provenance rule. On direct reads, release uses the model’s `@subject()` property or `id`. It must match the subject used when appending the event: the kernel encrypts with the event’s subject, stored as `__subject`. A mismatch fails release and therefore fails the query. A directly read model with only `@encrypted()` fields also needs `@subject()` or `id` in the TypeScript SDK; without either, release cannot identify the key and fails. An instance without a subject that holds no protected value, such as a masked copy that cleared its protected fields and dropped `id`, is served without a release; one that still holds a protected value fails the query. ### Release raw MongoDB documents `MongoReadModels` returns raw driver documents, not class instances. To have Arc release them, name the read model they hold: ```ts const people = new MongoReadModels({ client, databaseForTenant, filterFor, readModel: PersonView }, 'personViews'); ``` Each returned document is marked with the model, the request’s tenant, and a subject. By default, the subject is the `__subject` Chronicle stored with the document, which is the subject it encrypted with, and otherwise the document’s string or numeric `_id`. Use `subjectFor(document)` when neither holds the subject; it must still agree with any stored `__subject`. If a document has no usable subject, the read fails. When the query returns these documents directly, in an array, or in `queryPage`, Arc releases each one before serving it. This covers snapshots, pages, and observable emissions. Arc passes the document to `store.readModels.release` and serves `_id` plus the declared fields with Chronicle’s released values. Chronicle stores bookkeeping with every materialized document: `__lastHandledEventSequenceNumber`, `__initialized`, `__subject`, and `__subjects`. Arc does not release or serve these fields unless the read model declares them, just as the kernel strips them from its own reads. The query fails rather than serving stored values when: * the document has a field the read model’s schema does not declare, including a name that differs only by case or naming policy; * a value is not JSON: BSON `ObjectId`, `Binary`, and `Decimal128` are rejected, although `Date` is accepted. With the driver’s default promotion, an int64 within the safe integer range (±2^53) arrives as a JavaScript number and is accepted; only int64 values outside that range, which arrive as a BSON `Long` (or a `bigint` with `useBigInt64`), are rejected. A `Guid` field stored as a BSON `Binary` UUID therefore fails the read; the key in `_id` is served as stored and is not checked; * an object or array does not have a declared schema; * the document’s `id` or `@subject()` value, or its stored `__subject`, differs from the marked subject; * the document has per-property subjects in a non-empty `__subjects`, which Chronicle stores for models that join personal data from several subjects. Arc cannot release these yet; read such models through `ChronicleReadModels`; * the request’s tenant differs from the marked tenant; * Chronicle fails to release the document or omits a field; * a typed raw document is nested inside another returned shape, such as `MongoReadModels.page()`’s `MongoPage`. Return `queryPage` instead. * an instance of a protected read-model class is nested inside another returned shape, such as the object a joined `select((books, authors) => ({ books, authors }))` builds, unless Chronicle released it. Arc intercepts only the value a query returns, its array items, and its page items. Chronicle-released instances, from `ChronicleReadModels`, may be nested. The typed `find`, `findById`, `page`, and `queryPage` calls reject a MongoDB `projection`, because partial documents are DTO projections. ### What Arc does not release On the typed path, the boundary covers **exact read-model classes** and **raw documents typed with `readModel`**, when the model is registered in the Chronicle artifact catalog. Arc serves these as stored: * untyped `MongoReadModels` documents and other raw driver documents; * derived subtypes selected by the codec; * DTOs, mapped objects, and copies of typed raw documents, because a copy loses its marking; * typed raw documents that handler code hides where Arc’s check cannot see them: behind `toJSON()`, a getter, a private field, or inside a `Map` or `Set`. Arc only inspects own enumerable values. Return the documents themselves; * `MongoDBWatcher.changes()` payloads. The `fullDocument` of a change is a raw, unmarked document, so a protected model’s fields arrive as ciphertext. Release them yourself or read the changed model through a query; * typed raw documents of a model that is not registered with `withChronicle`, because Arc cannot tell whether such a model is protected. For these paths, call the tenant store’s `readModels.release` before serving protected data. In-memory sorting of a directly read array by an encrypted field orders by ciphertext; database-side sorting on encrypted fields is inherently meaningless. The live integration check inspects raw MongoDB storage for ciphertext and verifies plaintext through Chronicle HTTP snapshots, observable emissions, command injection, and an Arc query using `MongoCollection` to decode the materialized collection into the exact class. The raw `MongoReadModels` path is checked by specs that use a Chronicle substitute to serve snapshots, pages, and observable SSE through Express, Fastify, and Hono, and by the kernel integration, which reads a document the kernel materialized, bookkeeping included, through `MongoReadModels` on each adapter. Release is not authorization Decide separately who may read a person’s data. ## Subject and authentication are different The subject is whose data an event holds. The signed-in principal is who asked for the change. Arc never derives one from the other. A support agent correcting a customer’s address is the principal; the customer is the subject. ## Related * [Subject](/arc/backend/typescript/chronicle/commands/subject/) * [Causation and auditing](/arc/backend/typescript/chronicle/commands/causation/) * [Chronicle compliance](/chronicle/compliance/) * [Capability reference](/arc/backend/typescript/reference/capabilities/#persistence-and-chronicle) # The Cratis package > Register Arc and the experimental Chronicle integration with one call from @cratis/cratis, the TypeScript counterpart of the .NET Cratis package. An application that uses Arc and Chronicle together imports from `@cratis/arc.core` and `@cratis/arc.chronicle`, and calls `withChronicle` on every builder. `@cratis/cratis` composes the two: one import, one builder call. It is the TypeScript counterpart of the .NET `Cratis` package. Experimental `@cratis/cratis` is experimental like the integration it composes, and it is not published to npm. Pack it from a clone like the other packages; it depends on `@cratis/arc.core`, `@cratis/arc.chronicle`, and `@cratis/arc.testing`, so pack and install those too. ## Create the builder main.ts ```typescript import { CratisApplication } from '@cratis/cratis'; import { metadata } from './Features/generatedMetadata.js'; const builder = CratisApplication.createBuilder(); builder.useGeneratedMetadata(metadata); await builder.discover(new URL('./Features/', import.meta.url)); const app = await builder.build(); await app.run(); ``` `CratisApplication.createBuilder(options, chronicle)` creates an Arc builder with `options`, the same [configuration options](/arc/backend/typescript/configuration/) `ArcApplication.createBuilder` takes, and registers Chronicle with `chronicle`. Both default to empty, so the example above reads the event store and connection from `Cratis:Chronicle` in `appsettings.json` or the environment. [Registration options](/arc/backend/typescript/chronicle/registration-options/) lists the Chronicle options and the configuration keys. On a builder you created yourself, `builder.addCratis({ eventStore, connectionString })` does the same after you import `@cratis/cratis`. ## What it exports | Import | Re-exports | | --- | --- | | `@cratis/cratis` | `@cratis/arc.core` and `@cratis/arc.chronicle`, plus `CratisApplication` | | `@cratis/cratis/testing` | `@cratis/arc.testing` and `@cratis/arc.chronicle/testing` | ## What it does not add It does not install an authentication handler. If your routes need authentication, pass one in `CratisApplication.createBuilder({ authentication: [...] })`, as you would to `ArcApplication.createBuilder`; see [Authentication](/arc/backend/typescript/core/authentication/). Public routes need no handler. ## Related * [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/) * [Registration options](/arc/backend/typescript/chronicle/registration-options/) * [Packages](/arc/backend/typescript/reference/packages/) # Reactors > React to a recorded Chronicle event with a reactor, return follow-up events or Arc commands from it, and know how handlers are found and what a failure does. After an author registers, the catalog should build a shelf for them. The registration command should not do that work itself: the shelf belongs to another slice, and the registration is complete the moment the fact is recorded. A projection answers “what does this look like now?”. A **reactor** answers “what should happen because of this?”. Reactors are part of the Chronicle SDK, `@cratis/chronicle`. The Arc integration adds one thing to them: a reactor can return Arc commands, and Arc runs them through its command pipeline. See [Returning commands from a reactor](/arc/backend/typescript/chronicle/reactors/command-side-effects/). ## Write a reactor ShelfBuilder.ts ```typescript import { onceOnly, reactor } from '@cratis/chronicle/reactors'; import type { EventContext } from '@cratis/chronicle/events'; import { AuthorRegistered } from '../Registration/Registration.js'; import { CreateShelf } from './CreateShelf.js'; @onceOnly() @reactor() export class ShelfBuilder { authorRegistered(event: AuthorRegistered, context: EventContext): CreateShelf { return new CreateShelf(context.eventSourceId); } } ``` `CreateShelf` is an ordinary Arc `@command()` in your application. Register the reactor the way you register every other artifact, with `builder.add(...)` or `builder.discover(...)` after [`withChronicle`](/arc/backend/typescript/chronicle/add-event-sourcing/). A reactor has no Arc decorator, so one discovered before `withChronicle` is dropped unless [`activateArtifactsInScopes`](/arc/backend/typescript/chronicle/reactors/scoped-activation/) is on. The Arc-owned Chronicle client starts observing it when the application builds. ## How a handler is found The SDK looks for a method named after the event class in camelCase: `AuthorRegistered` is handled by `authorRegistered`. The parameter type plays no part in the match. A misspelled handler is silently ignored A method whose name does not match an event class is never called, and nothing reports it. Rename the event class and the handler stops running. Check the method name first when a reactor appears to do nothing. The first argument is the stored event content parsed from JSON, not an instance of your event class. Read its properties, but do not call its methods or test it with `instanceof`. A concept property arrives as its underlying primitive value, so `event.name` on `AuthorRegistered` is a string at runtime even though the class declares an `AuthorName`. The second argument is the event’s `EventContext`, which carries the event source ID, sequence number, occurred time, correlation ID, causation, and the identity that caused it. By default the SDK constructs the reactor itself. It has no dependency injection, so a reactor cannot take constructor services. To have Arc construct reactors with their services instead, see [Activate reactors and reducers in Arc scopes](/arc/backend/typescript/chronicle/reactors/scoped-activation/) (preview). ## What a handler can return | Return | What happens | | --- | --- | | Nothing | The event is acknowledged | | A registered event, or an array of them | The SDK appends them to the triggering event’s source and stream as one batch | | An `EventForEventSourceId` entry, or an array mixing entries and events | The SDK appends each entry to its own target | | An Arc `@command()` instance, or a nonempty array of only commands | Arc executes each command in order; see [Returning commands](/arc/backend/typescript/chronicle/reactors/command-side-effects/) | Returning commands and events together in one array fails the handler. Anything else the SDK does not recognize is ignored. ## When a handler fails A handler that throws, or a returned side effect that fails, marks the observer partition for that event source as failed, with the error message. Chronicle’s failed-partition handling decides when that event is delivered again. Nothing that already happened is undone, so write handlers that are safe to run twice for the same event. Reactors run on replay by default. For effects such as returned commands, mark the class with `@onceOnly()` so Chronicle never replays the reactor at all, neither a full replay nor a partition replay; a `@replay()` handler on such a class therefore never runs. Mark individual methods with `@onceOnly()` instead to skip only those handlers during a replay, and use `@replay()` to select a separate handler for a replayed event. See [Chronicle once-only reactors](/chronicle/reactors/once-only/). These markers do not prevent ordinary re-delivery after a failed partition recovers, so keep the effects safe to repeat. ## Topics | Topic | Description | | --- | --- | | [Returning commands](/arc/backend/typescript/chronicle/reactors/command-side-effects/) | Return Arc commands from a reactor and have Arc execute them with validation and authorization | | [Chronicle reactors](/chronicle/reactors/) | Reactor concepts in the Chronicle documentation | # Returning commands from a reactor > Return Arc commands from a Chronicle reactor so they run through validation and authorization, give them a system principal, and know the failure, tenancy, and retry rules. When a book is added to the catalog, the search index should follow. The indexing command already exists, with its validation and its role check. Instead of calling a service from the reactor and repeating those checks, return the command, and Arc runs it through the same pipeline an HTTP caller would use. This relies on the SDK’s reactor result hook. ## Return a command Catalog.ts ```typescript import { field } from '@cratis/fundamentals'; import { eventType, type EventContext } from '@cratis/chronicle/events'; import { onceOnly, reactor } from '@cratis/chronicle/reactors'; import { command, key, roles } from '@cratis/arc.core'; import { executeCommandsAsSystem } from '@cratis/arc.chronicle'; @eventType() export class BookAdded { @field(String) title: string; constructor(title = '') { this.title = title; } } @eventType() export class BookIndexed { @field(String) title: string; constructor(title = '') { this.title = title; } } @command() @roles('CatalogWriter') export class IndexBook { @field(String) @key() id = ''; @field(String) title = ''; constructor(id = '', title = '') { this.id = id; this.title = title; } handle(): BookIndexed { return new BookIndexed(this.title); } } @executeCommandsAsSystem('CatalogWriter') @onceOnly() @reactor() export class CatalogIndexer { bookAdded(event: BookAdded, context: EventContext): IndexBook { return new IndexBook(context.eventSourceId, event.title); } } ``` Register all four with `builder.add(...)` after `withChronicle`, or with `builder.discover(...)` in either order. When a `BookAdded` is appended, Chronicle calls `bookAdded`, and Arc executes `IndexBook`: authorization, validation, `provide()`, `handle()`, and the append of `BookIndexed`. The book ID comes from the triggering event’s context, not from the event payload. ## Who runs the command A reactor is not an HTTP request, so no caller is signed in. A returned command runs with **no principal** by default, as in Arc on .NET. A command without authorization rules runs normally; one with `@roles`, `@authorize`, or a policy is rejected. `@executeCommandsAsSystem('CatalogWriter')` on the reactor class gives the commands it returns an authenticated system principal with exactly the roles you list. Grant only the roles those commands need. The decorator covers returned commands only; a command you execute yourself inside the handler gets nothing from it. It does not prevent replay; `@onceOnly()` does that for this reactor on SDK 6.9.0 and later. | Value | Without the decorator | With the decorator | | --- | --- | --- | | Arc principal | None | System, with the listed roles | | Tenant | The triggering event’s namespace | Same | | Correlation ID | The triggering event’s correlation ID | Same | | Causation | A `ReactorEvent` entry with the event source ID, event type, sequence number, event store, and namespace, followed by the command | Same | Events the commands append carry Chronicle’s system identity in both cases: the command has no signed-in user, and the principal the decorator supplies is the system identity. Arc executes the commands with an allowed severity of Warning, so warnings do not block and errors do. ## Return several commands Return a nonempty array that contains only commands: ```typescript bookRemoved(event: BookRemoved, context: EventContext): (ArchiveBook | RemoveFromIndex)[] { return [new ArchiveBook(context.eventSourceId), new RemoveFromIndex(context.eventSourceId)]; } ``` This handler fragment assumes `BookRemoved` is an event type and `ArchiveBook` and `RemoveFromIndex` are commands in your application. Arc runs the commands in order and stops at the first one that fails. Each command is its own execution with its own [batch](/arc/backend/typescript/chronicle/commands/transactional-commands/): when the second fails, the first has already committed and stays committed. Do not mix commands with events or other values in one array. Arc rejects the mixture, and the handler fails, instead of silently dropping an item. Return either only commands or only events. ## When a command fails A returned command that fails, whether rejected by authorization or validation or by throwing, fails the handler with a message naming the command, the event store, and the namespace. Chronicle marks the observer partition as failed rather than acknowledging a partial side effect. When Chronicle delivers the event again, the handler returns the commands again, including any that succeeded the first time. Reactors accept explicit and kernel-initiated replays by default. Mark the class with `@onceOnly()` when all its handlers cause non-replayable effects: Chronicle then never replays the reactor at all, neither a full replay nor a partition replay, so a `@replay()` handler on that class never runs. If only some handlers cause such effects, mark those methods with `@onceOnly()` instead; Chronicle skips only them during a replay, and `@replay()` can supply an alternate replay handler. See [Chronicle once-only reactors](/chronicle/reactors/once-only/). Marking a reactor once-only does **not** prevent re-delivery when a failed partition is recovered. Make the commands safe to repeat even with `@onceOnly()`. Key them by the triggering event source, check current state in [`provide()` or a read model](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/), or rely on a Chronicle constraint to reject the duplicate. No transaction spans the triggering event and the commands. The triggering event is already committed when the reactor runs. ## Use a caller-owned client With an Arc-owned client (`{ connectionString, eventStore }`), `withChronicle` installs the result handler before observation starts. For a client you create yourself, pass `reactorCommandResultHandler` to the SDK **before the client connects**: main.ts (excerpt) ```typescript import { ChronicleClient, ChronicleOptions } from '@cratis/chronicle'; import { ArcApplication } from '@cratis/arc.core'; import { ChronicleArtifacts, reactorCommandResultHandler } from '@cratis/arc.chronicle'; import { BookAdded, CatalogIndexer, IndexBook } from './Catalog.js'; const artifacts = new ChronicleArtifacts(); for (const type of [BookAdded, CatalogIndexer, IndexBook]) artifacts.register(type); let application: ArcApplication | undefined; const client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { clientArtifactsProvider: artifacts, discoveryPatterns: [], reactorResultHandler: reactorCommandResultHandler(() => application!.server, 'Catalog') })); const builder = ArcApplication.createBuilder(); builder.withChronicle({ client, eventStore: 'Catalog' }); builder.add(BookAdded, CatalogIndexer, IndexBook); application = await builder.build(); ``` The SDK calls the handler only while observing, after `build()` has assigned `application`. The second argument names the event store Arc appends to; a reactor observing a different store fails instead of running commands in the wrong place. The handler declines results that contain no command, so events returned from other reactors keep the SDK’s own append path. ## Related * [Reactors](/arc/backend/typescript/chronicle/reactors/) * [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/) * [Command pipeline](/arc/backend/typescript/commands/command-pipeline/) * [Chronicle reactors](/chronicle/reactors/) # Activate reactors and reducers in Arc scopes (preview) > Opt in to constructing Chronicle reactors and reducers through Arc's service container, one scope per delivery, with the observation's tenant and correlation. Preview Scoped activation is off by default and its behavior can still change. Applications that do not opt in are unaffected. By default the Chronicle SDK creates reactors and reducers itself, so their constructors cannot take Arc services. With scoped activation, Arc resolves them from its container instead. ## Opt in Set `activateArtifactsInScopes` on an Arc-owned connection: main.ts (excerpt) ```typescript builder.withChronicle({ connectionString: 'chronicle://localhost:35000', eventStore: 'MyArcApp', activateArtifactsInScopes: true }); ``` Scoped activation requires `@cratis/chronicle` 6.29.0 or later, the lowest version `@cratis/arc.chronicle` accepts as a peer. To use a Chronicle client you create yourself, see [Use a caller-owned client](#use-a-caller-owned-client). With the option set, Chronicle-only artifacts that `discover(...)` found before `withChronicle` was called are registered with Chronicle too. Without it, registration is unchanged. With the option set, building the application checks each reactor’s and reducer’s registration and its constructor dependencies, without constructing any of them. A missing service, a constructor Arc cannot bind (for example constructor parameters without `inject` tokens or decorator metadata), or a singleton artifact that depends on a scoped service fails the build, and the error names the artifact. ## What a delivery gets For each delivery Chronicle makes to a reactor or reducer, Arc: * rejects it unless it comes from the configured event store; * creates a service scope and resolves the artifact from it, so constructor dependencies are shared by every event in the batch and not by the next batch; * sets `currentContext()` for each handler: the tenant is the observation’s namespace, the correlation is the handled event’s correlation, there is no principal, the signal is cancelled when Chronicle cancels the delivery or the application shuts down, and warnings are allowed; * pins Arc’s Chronicle services in the scope to the event store Chronicle delivered from: `ChronicleReadModels`, command read models, `commandAggregate` and the events commands return all use that store, never a store the application’s own Chronicle client looks up; * handles a replay notification in its own scope, with a newly generated correlation; * disposes the scope after the batch and before Chronicle acknowledges it. Commands a handler returns follow the rules in [Returning commands from a reactor](/arc/backend/typescript/chronicle/reactors/command-side-effects/): they run in the observation’s tenant with no principal, or with a system principal when the reactor uses `@executeCommandsAsSystem`. They run one at a time in the order returned, with the handled event’s correlation, and the first failure fails the delivery without running the rest. Each command receives the delivery’s signal and uses the delivery’s event store. Arc checks the signal before starting each command, so a cancelled delivery runs no further commands. A command that runs in another event store or namespace than the delivery fails instead of falling back to the application’s client. ### The tenant in a single-tenant application The tenant is the observation’s namespace as Chronicle reports it, so in an application without tenancy a handler sees `Default`, not an unset tenant. `Default` addresses the same data as an unset tenant: Chronicle maps both to the `Default` namespace, and the MongoDB and Drizzle integrations map it to the base database. Arc keeps `Default` rather than clearing it because those integrations require a tenant and fail without one, and because the commands a reactor returns already run with the namespace as their tenant, so a handler and its commands agree. A registration you make yourself still wins. A reactor registered as a singleton is shared by all deliveries and is disposed with the application, not after each batch; it cannot depend on scoped services. ## Failures * If constructing the artifact fails, Arc disposes the services it already created. When that cleanup also fails, the error combines both failures. * If disposing the scope fails, the delivery fails, even when every handler succeeded. When a handler also failed, both errors are kept. A failed delivery is retried. It is not rolled back: appended events, commands and other side effects that already ran stay done and can run again, so handlers must be idempotent. ## Shutdown When the application shuts down, Arc stops accepting new deliveries, aborts `currentContext().signal` for running handlers, and waits for them to finish and dispose their scopes. Only then does it dispose services, and an Arc-owned Chronicle client is closed last, so the connection stays open while admitted deliveries finish. Cancellation is cooperative: a handler that ignores the signal delays shutdown until it settles. A delivery that arrives after shutdown has started is rejected without touching services. ## Use a caller-owned client `chronicleArtifactActivator(server, eventStore)` from `@cratis/arc.chronicle` is the activator the option installs on an Arc-owned connection. For a Chronicle client you create yourself, pass it as `artifactActivator`, together with `reactorCommandResultHandler` as `reactorResultHandler`, and set `activateArtifactsInScopes` with the client: main.ts (excerpt) ```typescript import { ChronicleClient, ChronicleOptions } from '@cratis/chronicle'; import { chronicleArtifactActivator, reactorCommandResultHandler } from '@cratis/arc.chronicle'; let application: Awaited> | undefined; const server = () => application!.server; const client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { artifactActivator: chronicleArtifactActivator(server, 'MyArcApp'), reactorResultHandler: reactorCommandResultHandler(server, 'MyArcApp') })); application = await builder.withChronicle({ client, eventStore: 'MyArcApp', activateArtifactsInScopes: true }).build(); // Start observing with the client only now: the activator needs the built application. ``` Registration verifies only that the client was created with an activator from `chronicleArtifactActivator` for the same event store; passing `reactorCommandResultHandler(...)` as `reactorResultHandler` is your responsibility, and without it returned commands are not executed through Arc. Arc never changes the client’s options and never disposes the client. With the client registered this way, Arc checks the reactors’ and reducers’ registrations when building, as it does for an Arc-owned connection. The caller owns the ordering: * Build Arc before the client starts observing. A delivery that arrives earlier finds no application; the delivery fails and Chronicle retries it. * Returned commands receive the delivery’s signal and event store only when the client also has `reactorCommandResultHandler` as its `reactorResultHandler`. * Dispose Arc first, then the client: `await application.dispose()` stops the activator and waits for admitted deliveries while the connection is still open, then `client.dispose()` stops observing. Deliveries that arrive in between are rejected. # Chronicle read models > Serve a Chronicle projection from Arc queries with ChronicleReadModels, as a snapshot or a live observable, and know the consistency you get. Chronicle projects events into read models and keeps them stored. You still need a query to hand them to the client, scoped to the caller’s tenant, and ideally live so the screen updates when a new event lands. With the integration, one class is both the Chronicle read model and the Arc read model that serves it. ## One class, two roles The [Library sample’s author listing](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Listing/Listing.ts): ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel, service } from '@cratis/arc.core'; import { ChronicleReadModels } from '@cratis/arc.chronicle'; import type { Observable } from 'rxjs'; import { fromEvent } from '@cratis/chronicle/projections'; import { AuthorRegistered } from '../Registration/Registration.js'; import { AuthorId } from '../AuthorId.js'; import { AuthorName } from '../AuthorName.js'; @readModel() @fromEvent(AuthorRegistered) export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @query({ observable: true }, service(ChronicleReadModels)) static allAuthors(models: ChronicleReadModels): Observable { return models.observeAll(Author, author => author.id.toString()); } @query(service(ChronicleReadModels)) static async authorsPage(models: ChronicleReadModels): Promise { return models.getAll(Author); } } ``` Arc’s `@readModel()` exposes the queries. Chronicle infers the same class as its read model from `@fromEvent`, or from a projection or reducer that targets it; do not add Chronicle’s deprecated `@readModel()` decorator. `@fromEvent(AuthorRegistered)` copies the event’s matching properties, and the event source ID becomes `id`. Set `static readonly readModelId` only when you need to keep a custom stored identifier. `allAuthors` answers a GET with the current list and then pushes a new list on every change. `authorsPage` returns a snapshot, and Arc [pages and sorts](/arc/backend/typescript/queries/model-bound/paging/) the array in memory, which suits a small catalog but not an unbounded list. ## What ChronicleReadModels offers `ChronicleReadModels` is a scoped service bound to the current tenant’s event store. Inject it with `service(ChronicleReadModels)` in a query, or `@inject(ChronicleReadModels)` in a command. | Member | Returns | | --- | --- | | `getAll(Type)` | Every projected instance in the tenant | | `getById(Type, id)` or `findInstanceById(Type, id)` | One instance by event source ID, or `null` | | `observeAll(Type, key?)` | An RxJS `Observable`: a snapshot, then the updated list on every change | | `observeById(Type, id)` | An `Observable` that emits `null` when the instance is removed | | `watch(Type)` | An `Observable>` of raw changes | | `watchIterable(Type)` | The same changes as an async iterable, without RxJS | | `getStore()` | The tenant’s SDK `IEventStore`, for anything else | `observeAll` keys the list by each model’s `id`. Pass a key selector when your model names its identity differently, or when `id` is a concept, as `allAuthors` does with `author.id.toString()`. Unsubscribe, or let Arc end the subscription, to stop watching. SDK 6.9.1 and later omit the empty subscription marker from `watch()`; Arc also filters empty keys for older SDKs in its peer range. ## Where the read model is stored Chronicle stores the projected read model in a container, a MongoDB collection by default. When the application also uses `withMongoDB` and Arc creates the Chronicle client, a class listed in its `readModels` is stored in the collection Arc’s MongoDB integration reads, so `Author` is stored in `Authors` under the default naming policy. Any other read model, and every read model without `withMongoDB`, is stored under its identifier. `ChronicleReadModels` asks the kernel for the read model, so your queries never spell the name. See [Choose where read models are stored](/arc/backend/typescript/chronicle/registration-options/#choose-where-read-models-are-stored) for overrides and for a client you create yourself. ## Consistency * **Active projections are eventually consistent by default.** A command can succeed before its read model has updated. A client that reads right after a command can see the old state. An observable query catches up on its own. For a passive on-demand read or a bounded observer wait after a command, see [Read consistency](/arc/backend/typescript/queries/read-consistency/). * **The first list can miss a change.** `observeAll` and `observeById` read a snapshot, then subscribe to changes. A change that lands between the two is missed until the next change. Use `watch(Type)` and reconcile from the store when you need gap-free observation. * **Everything is tenant-scoped.** Reads use the current execution’s tenant as the namespace, like appends. ## Use state in a command A command can take the read model for its own key as a `handle()` or `provide()` parameter, and a validator can read it, with no query round-trip. See [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/). ## Related * [Observable queries](/arc/backend/typescript/queries/observable-queries/) * [When read model resolution fails](/arc/backend/typescript/chronicle/read-models/failures/) * [Returning events](/arc/backend/typescript/chronicle/commands/) # When read model resolution fails > Every failure a command-scoped read model can produce, what the caller sees, what it means, and how to fix it. A command that loads a read model can fail for two different kinds of reasons. The request can be wrong: no usable key, or an entity that does not exist. The application can be wrong: a read model no integration owns, or a lookup outside a validator. Arc keeps the two apart. Wrong input answers 400 with a validation result, and a misconfiguration fails loudly instead of posing as a missing entity. | Message | Cause | Caller sees | | --- | --- | --- | | `A command key is required for ` | The command has no usable key | 400 | | ` was not found for the command key` | The key is valid, but a required read model does not exist | 400 | | `Expected one read-model resolver for , found 0` | No integration owns the type | `build()` fails | | `Expected one read-model resolver for , found 2` | Two integrations claim the type | `build()` fails | | `Command read models can only be resolved during command validation` | `readModelForValidation` was called outside a running validator | The call throws | Both 400 answers carry one validation result with error severity, reason `rule`, and no members. The command’s own code does not run. ## A command key is required The command resolved no key, so there is nothing to look the read model up by. The key comes from `getEventSourceId()`, a `getKey()` method, or a `@key()` field; see [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/). An empty string counts as no key. Making the parameter optional does not help: `commandReadModel(Type, { optional: true })` still rejects a command without a key, because the lookup cannot be performed at all. A keyless command still appends A Chronicle command with no key appends its events to a new UUID. That UUID is created when the events are appended, after the read model would have been loaded, so it never serves as a lookup key. A command that works on an existing entity must declare that entity’s key. **Fix:** declare the key on the command. ## Not found for the command key The key is valid, no instance exists for it, and the parameter is required. Arc treats this as invalid input: the command targets an entity that is not there. **Fix:** pick the one that matches your intent. * **Absence is a business condition.** Declare `commandReadModel(Type, { optional: true })`, or read it with `readModelForValidation(Type, { optional: true })` in a validator, and write the rule around `null`. You get your own message instead of the generic one. * **The state is required.** Keep the parameter required. The 400 is the intended behavior. A projection runs after the event is appended. A command sent straight after the event that creates the entity can arrive before the read model exists. For a rule that must hold regardless of timing, use a Chronicle constraint. ## No owner, or two owners `build()` checks every `commandReadModel(Type)` binding in `handle()` and `provide()` before the application serves anything. A type needs exactly one owner: * **Chronicle** owns a type that is a read model in its catalog: projected with `@fromEvent` or another projection decorator, or targeted by a projection or reducer, and registered through `discover()` or `add()`. Discovery can run before or after `withChronicle`; a class not registered at all is not in the catalog. * **MongoDB** owns a type listed in `withMongoDB({ readModels })`. * An application can add its own `ReadModelForCommandResolver` with `builder.addReadModelForCommandResolver(...)`. Two owners fail too, instead of one silently winning. Remove the type from one integration. `readModelForValidation` is resolved when the validator runs, not at `build()`. A missing owner there makes the rule throw, and the caller gets a 400 with reason `validatorFailed`. ## Outside a validator `readModelForValidation` reads the key and tenant from the validator that is running. Called anywhere else, such as in `handle()` or a query, it has no command to read from and throws. Use `commandReadModel(Type)` in `handle()` and `provide()`. ## Related * [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/) * [Command validation](/arc/backend/typescript/commands/command-validation/) * [Diagnostics](/arc/backend/typescript/reference/diagnostics/) # Read models in commands > Take the Chronicle read model projected for a command's key as a handle() or provide() parameter with commandReadModel, read it in a validator with readModelForValidation, and decide what a missing instance means. A member reserves a book. The reservation event should carry the book’s title, and the command should refuse a book that is not in the catalog. Both answers are already in the `Book` read model Chronicle keeps for that book. You should not need to write a query, call it from the command, and map “not found” to an error yourself. Arc loads the read model whose ID is the command’s key and passes it in. There are three places to take it: | Position | Use it when | Declare it with | | --- | --- | --- | | `handle()` | The event you return is computed from the state | `@inject(commandReadModel(Book))` | | `provide()` | The state is combined with other fetched data before `handle()` decides | `@inject(commandReadModel(Book))` | | A command validator | The command should be rejected with your own message | `await readModelForValidation(Book, { optional: true })` in an async rule | All three read the same instance: Arc loads each read model type at most once per command execution. ## Take it in handle() Reservation.ts ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { fromEvent } from '@cratis/chronicle/projections'; import { command, commandReadModel, inject, key } from '@cratis/arc.core'; @eventType() export class BookAdded { @field(String) title: string; constructor(title = '') { this.title = title; } } @eventType() export class BookReserved { @field(String) title: string; @field(String) member: string; constructor(title = '', member = '') { this.title = title; this.member = member; } } @fromEvent(BookAdded) export class Book { @field(String) id = ''; @field(String) title = ''; } @command() export class ReserveBook { @field(String) @key() bookId = ''; @field(String) member = ''; @inject(commandReadModel(Book)) handle(book: Book): BookReserved { return new BookReserved(book.title, this.member); } } ``` Register the command, the events, and `Book` with `builder.add(...)` after `withChronicle`, or use `builder.discover(...)` before or after it. `ReserveBook` for a book in the catalog appends `BookReserved` with the book’s title. For a book that is not, it answers 400 with `Book was not found for the command key`, and `handle()` never runs. `Book` is a Chronicle read model because `@fromEvent` projects it. Add Arc’s `@readModel()` and queries when clients should read it too; see [Chronicle read models](/arc/backend/typescript/chronicle/read-models/). ## Take it in provide() `provide()` receives the read model the same way, and its return value becomes the first `handle()` argument: ```typescript @inject(commandReadModel(Book)) provide(book: Book): string { return book.title.trim(); } handle(title: string): BookReserved { return new BookReserved(title, this.member); } ``` This fragment replaces the `handle()` method of `ReserveBook`. Use `provide()` when the state has to be combined with something else you fetch, such as a rate or a policy, before `handle()` decides. See [Model-bound commands](/arc/backend/typescript/commands/model-bound/#prepare-data-in-provide). ## Read it in a validator Validators do not take read models as constructor parameters. Call `readModelForValidation` inside an asynchronous rule instead: ```typescript import { CommandValidator, readModelForValidation, validator } from '@cratis/arc.core'; import { Book, ReserveBook } from './Reservation.js'; @validator(ReserveBook) export class ReserveBookValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.bookId) .mustAsync(async () => await readModelForValidation(Book, { optional: true }) !== null) .withMessage('The book is not in the catalog'); } } ``` `readModelForValidation` uses the command’s key and the caller’s tenant; it takes no ID, so a rule cannot read another entity by accident. It works only in validators of model-bound commands, while they run. Pass `{ optional: true }` and write the rule around `null`. Without it, a missing model throws inside the rule, and Arc reports a failed validator (reason `validatorFailed`) instead of your message. ## Required or optional The key tells Arc which instance to load. It does not prove that instance exists. Say what absence means: | Declaration | Missing instance | | --- | --- | | `commandReadModel(Book)` | The command is rejected with 400 before your code runs | | `commandReadModel(Book, { optional: true })` | Your code receives `null` | Choose optional when absence is a state your rule handles, such as “register only if not registered yet”. Keep it required when the command cannot mean anything without the state. A read model that was projected asynchronously can lag behind the event that created it, so a command sent straight after creation can find nothing yet. For a rule that must hold regardless, use a Chronicle constraint. [When read model resolution fails](/arc/backend/typescript/chronicle/read-models/failures/) lists every failure. ## Which read models qualify * Chronicle resolves a type only when it is a read model in the application’s Chronicle catalog: projected with `@fromEvent` or other model-bound projection decorators, or targeted by a projection or reducer, and registered through `discover()` (in either order) or `add()` after `withChronicle`. * A MongoDB read model listed in `withMongoDB({ readModels })` is loaded the same way, from its collection; see [MongoDB](/arc/backend/typescript/mongodb/). Exactly one integration may own a type: `build()` fails when none or two claim it. * The Drizzle integration does not resolve command read models. ## Combine it with an aggregate A command can take both: projected state as context, and an [aggregate](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/) as the thing that changes. List both in `@inject(...)`, in the order of the `handle()` parameters. The read model is a snapshot; it does not lock anything, and it can lag. The aggregate’s revision is what protects the decision. ## Test it `ChronicleCommandScenario.givenReadModel(Book, 'book-1', instance)` pins the instance `commandReadModel(Book)` receives. See [Testing Chronicle commands](/arc/backend/typescript/testing/chronicle/). ## Related * [When read model resolution fails](/arc/backend/typescript/chronicle/read-models/failures/) * [Load a read model by key](/arc/backend/typescript/commands/command-context/#load-a-read-model-by-key) * [Command validation](/arc/backend/typescript/commands/command-validation/) # Chronicle registration options > The options withChronicle accepts, where it reads them from configuration, which one wins, who owns the Chronicle client, and what registration rejects. `withChronicle` connects an Arc application to one Chronicle event store. This page lists what it accepts and what it does with each value. [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/) walks through a first registration against a local kernel. ## Two ways to call it main.ts (excerpt) ```typescript import '@cratis/arc.chronicle'; builder.withChronicle({ connectionString: 'chronicle://localhost:35000', eventStore: 'MyArcApp' }); ``` Importing `@cratis/arc.chronicle` adds the `withChronicle` method to the Node builder and to the Fetch API builder from `@cratis/arc.core/fetch`. The package also exports the function `withChronicle(builder, options)`, which does the same; the [Library sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/main.ts) uses that form. The integration records the event types, projections, reducers, reactors, and constraints Arc discovers. A class with Arc metadata is recorded whether `discover(...)` runs before or after `withChronicle`. A Chronicle-only class, one without an Arc decorator, is picked up only when `withChronicle` runs first, so call it before `discover(...)`. The same goes for `add(...)`, which otherwise rejects such a class. With [`activateArtifactsInScopes`](/arc/backend/typescript/chronicle/reactors/scoped-activation/) on, Chronicle-only classes discovered earlier are picked up too. Arc-owned clients pass the registered artifacts to the SDK and set `discoveryPatterns: []`, so they do not depend on SDK file scanning. For a caller-owned client, import and register your artifacts explicitly or configure its `discoveryPatterns`. Since SDK 6.10.0, compiled JavaScript entry points no longer scan `.ts` files by default; explicit patterns still apply. Each recorded reactor and reducer also gets a scoped service registration in Arc, but only when nothing else registers that class. A registration in `builder.services` (before or after `withChronicle`), an entry in `options.services`, or an Arc lifetime decorator such as `@singleton()` always wins, and discovering an artifact twice does not add a second registration. With a caller-supplied `ServiceRegistry` in `options.services`, Arc adds no such registrations; register the artifacts yourself. By default Arc does not construct reactors or reducers through these registrations; the Chronicle SDK creates them. To opt in, see [Scoped activation](/arc/backend/typescript/chronicle/reactors/scoped-activation/). ## Options | Option | Type | Required | Meaning | | --- | --- | --- | --- | | `eventStore` | `string` | Yes | The event store every append and read uses. Chronicle creates it on first use | | `connectionString` | `string` | One of `connectionString` and `client` | Arc creates, connects, and disposes the SDK client | | `client` | `IChronicleClient` from `@cratis/chronicle` | One of `connectionString` and `client` | You own the client; see [Choose who owns the client](#choose-who-owns-the-client) | | `completionTimeoutMs` | positive integer, milliseconds | No; no wait by default | After each successful append, wait until Chronicle’s observers have processed it before the command answers. See [Choose Chronicle read consistency](/arc/backend/typescript/queries/read-consistency/) | | `readModelNamingPolicy` | `(identifier, readModelType?) => string` | No | Only with `connectionString`. Names the container each read model is stored in. Defaults to Arc’s MongoDB collection name when `withMongoDB` is configured, otherwise to the read model identifier. See [Choose where read models are stored](#choose-where-read-models-are-stored) | | `activateArtifactsInScopes` | `boolean` | No; off by default | Preview. Resolve reactors and reducers from Arc’s container, one scope per delivery. With `client`, registration verifies only that the client was created with `chronicleArtifactActivator`; passing `reactorCommandResultHandler` as `reactorResultHandler` is up to you. See [Scoped activation](/arc/backend/typescript/chronicle/reactors/scoped-activation/) | Registration throws `Chronicle requires eventStore and exactly one of connectionString or client` when the event store is missing, or when neither or both of a connection string and a client are set. Every append and read uses the current execution’s tenant as the Chronicle namespace, or the `Default` namespace when no tenant is resolved. Tenancy you configure for Arc therefore also isolates events and read models. See [Tenancy](/arc/backend/typescript/tenancy/). Development credentials `chronicle://localhost:35000` without credentials uses the SDK’s development client and accepts the kernel’s self-signed certificate. In production, put real client credentials in the connection string and validate the kernel’s certificate. With SDK 6.10.0, `getEventStore(...)` rejects after three consecutive credential rejections instead of waiting indefinitely. [Chronicle connection strings](/chronicle/connection-strings/) lists the parameters. ## Read the connection from configuration `withChronicle` reads `Cratis:Chronicle` from the application’s configuration: the `appsettings.json` in the working directory, its environment variant, and `Cratis__...` environment variables, as Arc does for its own [configuration](/arc/backend/typescript/configuration/). appsettings.json ```json { "Cratis": { "Chronicle": { "connectionString": "chronicle://localhost:35000", "eventStore": "MyArcApp" } } } ``` With that file, call `builder.withChronicle({})`. Keys are case-insensitive. To override the file in a deployment, set `Cratis__Chronicle__ConnectionString` and `Cratis__Chronicle__EventStore`. Only these two keys are read from configuration; `client`, `completionTimeoutMs`, and `readModelNamingPolicy` are set in code. Values follow this precedence: 1. Options passed in code win over configuration. 2. Environment variables win over `appsettings.json`. 3. A `client` passed in code replaces a configured connection string, so the two never collide. ## Choose who owns the client | Registration | Ownership | | --- | --- | | `{ connectionString, eventStore }` | Arc creates the SDK client with an artifact catalog for this application, and closes it when the application is disposed | | `{ client, eventStore }` | You pass a caller-owned `IChronicleClient`. Arc never disposes it; your host calls `client.dispose()`. The client must already have an artifact provider that registers the event types, projections, reducers, and reactors you use | An Arc-owned client is also wired so that [reactors can return Arc commands](/arc/backend/typescript/chronicle/reactors/command-side-effects/). A caller-owned client needs that handler passed to the SDK before it connects; the reactor page shows how. To use [scoped activation](/arc/backend/typescript/chronicle/reactors/scoped-activation/#use-a-caller-owned-client) with a caller-owned client, also pass `chronicleArtifactActivator` as its `artifactActivator`. Dispose the Arc application before the client, so deliveries still running finish while the connection is open. ## Choose where read models are stored A projected read model is stored in a container, which is a MongoDB collection by default. The Chronicle SDK names it after the read model identifier unless it is given a `readModelNamingPolicy`, a function of the identifier and, when the SDK knows it, the read model class. It changes only the container name. * **With `withMongoDB`.** An Arc-owned client gets a policy that returns the collection Arc’s MongoDB integration reads for a class listed in `readModels`, so a projected read model lands where your queries look with no configuration. A class outside that list, and a read model known only by identifier, keep the identifier. This holds whichever of `withMongoDB` and `withChronicle` you call first. See [Naming policies](/arc/backend/typescript/mongodb/naming-policies/#chronicle-projected-read-models) for the rule and for upgrading. * **With your own `readModelNamingPolicy`.** It replaces the automatic policy. Return the identifier when `readModelType` is undefined. * **Without `withMongoDB`.** Arc adds no policy, and the SDK default, the identifier, applies. * **With a caller-owned `client`.** Arc never changes the client, so it sets no policy, and `withChronicle` throws if you also pass `readModelNamingPolicy`. Set the policy in the `ChronicleOptions` you create the client with. The option needs `@cratis/chronicle` 6.29.0 or later. ## Related * [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/) * [The Cratis package](/arc/backend/typescript/chronicle/cratis-package/) * [Configuration](/arc/backend/typescript/configuration/) # Resolving the event source ID > How the Chronicle integration picks the event source for returned events, how to return it to the caller, and how to route events to other sources and streams. Every event belongs to an event source, such as one task. The integration takes the event source from the command unless you say otherwise. ## The default: the command key | Command declares | Event source ID | | --- | --- | | `getEventSourceId()` | Its result; this takes precedence | | A `@key()` field | That field’s value | | Neither | A new UUID for each execution | The key is the same one Arc resolves for the [command context](/arc/backend/typescript/commands/command-context/#give-a-command-a-key), so read models and aggregates loaded for the command use the same event source. `getEventSourceId()` accepts a string, a Fundamentals `Guid`, or a concept wrapping a primitive or `Guid`; Arc converts it to the event source ID string. Unsupported objects fail with `The command provided an invalid event source id`, rather than producing an ambiguous object name. An empty string counts as no value, and the `@key()` field is used instead. ## Return the ID to the caller `tuple(eventSourceIdResponse(id), event)` appends the event to `id` and returns `id` as the command’s response, overriding the key. An ordinary `tuple(event, 'message')` still appends to the command key. ## Target another event source ```typescript import { eventForEventSourceId } from '@cratis/arc.chronicle'; handle() { return eventForEventSourceId({ eventSourceId: 'routed', event: new Registered(), eventSourceType: 'Override', subject: 'subject-1', tags: ['tag-1'] }); } ``` This excerpt is from the package’s [scenario spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Chronicle/testing/for_ChronicleCommandScenario/when_executing/with_returned_events.ts). The explicit route, subject, occurred time, and tags override command defaults. The value is branded, so an ordinary DTO with `event` and `eventSourceId` fields is never mistaken for an event. The SDK’s `@tag` and `@tags` on event classes still apply. ## Set command defaults with decorators Class decorators from `@cratis/arc.chronicle` set routing defaults for every event a command returns: | Decorator | Sets | | --- | --- | | `@eventSourceType('Task', { concurrency? })` | The event source type | | `@eventStreamType('Onboarding', { concurrency? })` | The event stream type | | `@eventStreamId('main', { concurrency? })` | The event stream ID | | `@eventSubject('subject')` | The compliance subject; see [Subject](/arc/backend/typescript/chronicle/commands/subject/) | `{ concurrency: true }` reads that dimension’s tail immediately before the append and scopes it; see [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/). ## Related * [Returning events](/arc/backend/typescript/chronicle/commands/) * [Event metadata](/arc/backend/typescript/chronicle/commands/event-metadata/) * [Command context](/arc/backend/typescript/commands/command-context/) # Analyze Arc server artifacts > ESLint 10 rules for model-bound Arc commands, queries, and validators. Use `@cratis/eslint-plugin-arc-core` to find model-bound binding mistakes before you build or start an Arc server. This is a publishable 0.x source preview (not yet published); it does not lint the existing Arc frontend packages. The existing `@cratis/eslint-plugin-arc` name belongs to the frontend repository. Configure ESLint 10 with `typescript-eslint` (which installs `@typescript-eslint/parser`) and type information for the binding rules: ```js import arc from '@cratis/eslint-plugin-arc-core'; import tseslint from 'typescript-eslint'; export default tseslint.config( ...tseslint.configs.recommended, arc.configs['recommended-type-checked'] ); ``` Run `eslint src`. Both presets enable the mapped ARC diagnostics, three Chronicle rules, and TypeScript-only rules below, except the opt-in name rule. `recommended` works without type information: type-dependent comparisons are skipped, while syntax checks still run. `recommended-type-checked` enables `parserOptions.projectService` to check parameter and token types and adds `arcchr0006` and `arcchr0010`; files without an applicable tsconfig must be excluded or added to the project service. Both presets require a TypeScript parser. Arc decorators must resolve to imports from `@cratis/arc.core`, and `@field` must come from `@cratis/fundamentals` (including aliases and namespace imports); custom wrappers are not recognized. Runtime and proxy-generator checks remain authoritative. ## ARC diagnostic mapping The .NET IDs are retained only when the same mistake can happen in the TypeScript API. The columns distinguish a behavior analog from a verbatim Roslyn rule. | .NET diagnostic | TypeScript status | Reason | | --- | --- | --- | | ARC0001 | N/A | TypeScript queries intentionally permit arbitrary result shapes; they do not enforce a read-model return type. | | [ARC0002](/arc/backend/typescript/code-analysis/arc0002/) | Analog | A class with public fields and an instance `handle()` may be an undecorated command. | | [ARC0003](/arc/backend/typescript/code-analysis/arc0003/) | Analog | External `handle(command: DecoratedCommand)` methods do not run as command handlers. | | [ARC0004](/arc/backend/typescript/code-analysis/arc0004/) | Analog | A command needs an instance `handle()` (including inherited handlers); TypeScript private is runtime-callable. | | [ARC0005](/arc/backend/typescript/code-analysis/arc0005/) | Analog | A direct `provide()` value with no handler parameters is discarded; this rule does not infer control outcomes or nested returns. | | ARC0006 | N/A | `commandReadModel(Type)` rejects a missing model unless you pass `{ optional: true }`, so the choice is already explicit in the declaration. | | ARC0007–ARC0009 | N/A | TypeScript has classes, not C# records; Concepts are classes extending `ConceptAs`. | | [ARC0010](/arc/backend/typescript/code-analysis/arc0010/) | Analog | Async `handle()` without await, for-await, or a returned value wraps a synchronous result. | | ARC0011 | N/A | TypeScript does not have `nameof`; roles are strings. | | [ARC0012](/arc/backend/typescript/code-analysis/arc0012/) | Analog | Built-in errors from decorated artifacts obscure domain failures. | | [ARC0013](/arc/backend/typescript/code-analysis/arc0013/) | N/A | Arc captures `ruleFor` paths using a Proxy, does not invoke selectors on input, walks paths null-safely, and unwraps concepts automatically. | | [ARC0014](/arc/backend/typescript/code-analysis/arc0014/) | Analog | HTTP cannot supply generic type arguments to `@query`. | | [ARC0015](/arc/backend/typescript/code-analysis/arc0015/) | Analog | Primitive query parameter converted to `ConceptAs` inside a query skips concept validation; command handler parameters are service or provided values, not wire inputs. | | ARC0016–ARC0018 | N/A | TypeScript operations are explicit returned `CommandOperation` objects, not C# annotated methods or generated invokers. | | [ARC0019](/arc/backend/typescript/code-analysis/arc0019/) | Analog | `@allowAnonymous()` conflicts with `@authorize()` or `@roles()` on one declaration. | | ARC0020–ARC0021 | N/A | ASP.NET Core attributes and authentication schemes do not exist in this Node host. | Chronicle analogs: [ARCCHR0003](/arc/backend/typescript/code-analysis/arcchr0003/), type-checked [ARCCHR0006](/arc/backend/typescript/code-analysis/arcchr0006/), [ARCCHR0007](/arc/backend/typescript/code-analysis/arcchr0007/), [ARCCHR0009](/arc/backend/typescript/code-analysis/arcchr0009/), and type-checked [ARCCHR0010](/arc/backend/typescript/code-analysis/arcchr0010/). See [Chronicle code analysis](/arc/backend/typescript/chronicle/code-analysis/) for the complete .NET ARCCHR mapping, including diagnostics without TypeScript equivalents. TypeScript-only rules: [missing-field](/arc/backend/typescript/code-analysis/missing-field/), [declared-field](/arc/backend/typescript/code-analysis/declared-field/), [inject-binding](/arc/backend/typescript/code-analysis/inject-binding/), [query-binding](/arc/backend/typescript/code-analysis/query-binding/), [query-argument-name](/arc/backend/typescript/code-analysis/query-argument-name/) (opt-in), [misplaced-decorator](/arc/backend/typescript/code-analysis/misplaced-decorator/), [unexported-artifact](/arc/backend/typescript/code-analysis/unexported-artifact/), and [validator-target](/arc/backend/typescript/code-analysis/validator-target/). No automatic fix is offered: .NET’s `Task` unwrap fix changes async rejection semantics in JavaScript; its `nameof` fix has no TypeScript equivalent. Manual fixes are shown on each page. # ARC0002: Command-like class is not decorated > Mark handler-shaped classes as Arc commands. ## What A concrete class has a public instance field and an instance `handle()` but no `@command()`. Rule: `arc-core/arc0002`. Abstract classes, static handlers, and classes ending in `Helper` or `Extensions` are excluded. With type information, a handler taking an Arc command is left to ARC0003. ## Why Arc does not discover an undecorated command; `handle()` alone is not a route. A service with no public instance fields is not assumed to be a command. ## Bad ```ts export class Register { title!: string; handle() {} } ``` ## Good ```ts @command() export class Register { @field(String) title!: string; handle() {} } ``` If the class is a service rather than a command, no decorator is needed. # ARC0003: External command handler > Keep command handling on the decorated class. ## What An undecorated class declares `handle(command: DecoratedCommand)`. Rule: `arc-core/arc0003` (requires type information). ## Why Arc executes `handle()` on the command instance, not an external handler class. This rule checks a directly typed `@command()` class, not indirect aliases. ## Bad ```ts export class RegisterHandler { handle(command: Register) {} } ``` ## Good ```ts @command() export class Register { handle() {} } ``` # ARC0004: Command has no instance handle > Put an instance handler on each decorated command or its base class. ## What `@command()` requires an instance `handle()` method, either on the command or inherited from a base class. Rule: `arc-core/arc0004`. ## Why A missing or static handler cannot be invoked through the command pipeline. TypeScript `private` and `protected` only restrict compile-time access: the runtime can invoke those handlers, so the rule does not reject them. ## Bad ```ts @command() export class Register { static handle() {} } ``` ## Good ```ts @command() export class Register { handle() {} } ``` # ARC0005: Provided value has no consumer > Bind preparation values to command handlers. ## What A `provide()` method directly returns data while `handle()` accepts no parameters. Rule: `arc-core/arc0005`. ## Why The value is prepared but cannot be used by the handler. This rule only checks direct returns, not conditional or nested branches; control outcomes such as `rejected(...)` and `denied(...)` are exempt. ## Bad ```ts @command() export class Register { provide() { return new Tasks(); } handle() {} } ``` ## Good ```ts @command() export class Register { provide() { return new Tasks(); } handle(tasks: Tasks) { tasks.register(); } } ``` # ARC0010: Synchronous handler wrapped in a Promise > Avoid unnecessary async handlers without await. ## What An async command `handle()` has no `await`, `for await`, or returned asynchronous value. Rule: `arc-core/arc0010`. ## Why Arc accepts synchronous responses directly. Do not remove `async` mechanically when it changes an existing promise-returning contract or rejection semantics. ## Bad ```ts @command() export class GetValue { async handle() { return 3; } } ``` ## Good ```ts @command() export class GetValue { handle() { return 3; } } ``` # ARC0012: Built-in error thrown from an artifact > Give domain failures a named error. ## What A decorated command, validator, or read model throws `new Error` (or another built-in error). Rule: `arc-core/arc0012`. ## Why A domain-named error identifies the failure in logs and direct calls. This checks `throw new ...`, not indirect calls or imported aliases. ## Bad ```ts @command() export class Register { handle() { throw new Error('Already registered'); } } ``` ## Good ```ts @command() export class Register { handle() { throw new AlreadyRegistered(); } } ``` # ARC0013: Not applicable in Arc for TypeScript > Validator selectors are captured as paths rather than evaluated on input. ARC0013 is not implemented by the TypeScript ESLint plugin. `ruleFor` records the selector path through a Proxy; it never executes the selector against the input model. Validation walks that path null-safely and unwraps concept values automatically, so accessing `input.title.value` in the selector does not throw before a rule runs. There is no corresponding rejection rule to enable. # ARC0014: Generic query method > Remove unbound type parameters from model-bound queries. ## What A method decorated with `@query()` declares a type parameter. Rule: `arc-core/arc0014`. ## Why An HTTP caller cannot choose the generic argument of a discovered query. ## Bad ```ts @query() static all(): T[] { return []; } ``` ## Good ```ts @query() static all(): TaskItem[] { return []; } ``` # ARC0015: Raw parameter converted to a concept > Validate incoming concepts at the binding boundary. ## What A `@query` method converts a primitive parameter with `new Concept(parameter)`. Rule: `arc-core/arc0015` (requires type information). ## Why The query pipeline validates the incoming declared parameter, not a concept constructed later in the method body. Command `handle()` parameters are services or provided values, not wire input. Review optional and search arguments before changing their type. ## Bad ```ts @query(argument('id', String)) static byId(id: string) { return tasks.byId(new TaskId(id)); } ``` ## Good ```ts @query(argument('id', TaskId)) static byId(id: TaskId) { return tasks.byId(id); } ``` # ARC0019: Contradictory authorization > Do not grant and restrict access on the same declaration. ## What A class or method has both `@allowAnonymous()` and `@roles()` or `@authorize()`. Rule: `arc-core/arc0019`. ## Why Arc rejects conflicting authorization. A class-level restriction overridden by a method-level anonymous declaration is a different, supported case. ## Bad ```ts @allowAnonymous() @roles('Admin') export class Register { handle() {} } ``` ## Good ```ts @roles('Admin') export class Register { handle() {} } ``` # ARCCHR0003 — Reactor must not reach the default event log > Return Chronicle events from a reactor instead of appending to its own default log. A reactor can return events to Chronicle. The rule finds direct `this..eventLog.append(...)` or `appendMany(...)` (including `.transactional`) when the field is initialized or assigned earlier in the same method with `this.client.getEventStore(...)` or `this.runtime.getStore(...)`. It does not report fields initialized from `other.getEventStore('elsewhere')`, uninitialized store fields, reads, outbox writes, callbacks inside handlers, or helper calls. The `reactor` decorator must come from `@cratis/chronicle` or `@cratis/chronicle/reactors`. The SDK has no reactor DI ownership contract: even for a matching `this.client`/`this.runtime` field the rule cannot prove that client or store belongs to the reactor. ```ts // Instead of: await this.store.eventLog.append(id, new FollowedUp()); return new FollowedUp(); ``` This is an analog of the .NET warning, not a complete data-flow analysis. Other direct SDK appends still need review. [Chronicle code analysis](/arc/backend/typescript/chronicle/code-analysis/) maps the other diagnostics. # ARCCHR0006 — Reactor returning commands needs a replay decision > Declare how a Chronicle reactor that returns an Arc command behaves during replay. A reactor returning an Arc `@command()` instance (or an array of commands) runs that command again when Chronicle replays the event. Decide whether replay should run the effect: mark the reactor or individual handler `@onceOnly()` to skip it, or provide an alternate `@replay()` handler for the same event. This is a type-checked analog of the .NET warning for `ICommandPipeline.Execute`; ESLint reports it as an error in the `recommended-type-checked` preset. ```ts import { onceOnly, reactor } from '@cratis/chronicle/reactors'; @reactor() class StockKeeping { @onceOnly() bookReserved(event: BookReserved): DecreaseStock { return new DecreaseStock(event.isbn); } } ``` `@onceOnly()` is a replay decision, not duplicate protection: failed-partition recovery can still re-deliver an event and run the command again. Do not use once-only if replay needs to rebuild the side effect. Instead use `@replay()` on a separate method: `replayBookReserved()` for an event class named `BookReserved`, or `@replay(BookReserved)` on a differently named method. Chronicle chooses handlers by the event class name, **not** by the TypeScript type annotation of the first parameter. An alternate replay handler takes over for that event even if it returns nothing. See [Returning commands from a reactor](/arc/backend/typescript/chronicle/reactors/command-side-effects/#when-a-command-fails). The rule recognizes `@reactor()`, `@onceOnly()`, and `@replay()` imported from `@cratis/chronicle/reactors` or `@cratis/chronicle`, and return expressions whose checked type is an Arc `@command()` class (including top-level promises and one level of arrays). It follows returned `this.method()` calls inside the same reactor through helpers and reports at the return site, naming undecided handlers that reach it. Reactor class declarations and decorated class expressions are supported. A replay handler must name that event by convention or pass its constructor to `@replay(Event)`; a replay handler for another event does not count. This is bounded static analysis: a live handler must have the event class’s camel-cased name **and** a first parameter typed as that same decorated `@eventType()` class. The SDK dispatches by name alone, so a handler with no parameter, an `any` parameter, or a parameter typed as another event class is missed. Decorators on classes from compiled shared packages are absent from their `.d.ts` files, so those event and command classes are also missed. The rule does not prove artifact registration, follow calls through other objects or inherited helpers, infer commands behind erased `unknown`/`any` return types, or find commands executed manually through an Arc server instead of returned. Conditional returned helpers are treated as reachable; calls whose results are discarded are not. It does not assess whether replay and retries are safe for a particular command. [Chronicle code analysis](/arc/backend/typescript/chronicle/code-analysis/) maps the remaining diagnostics. # ARCCHR0007 — Command handler must not append directly to the event log > Return events from a Chronicle-backed command handler to preserve its append pipeline. A decorated command’s `handle()` should return events for the Chronicle integration to append. Direct appends from `handle()` or `provide()` to `eventLog.append(...)` or `appendMany(...)` (including `.transactional`) bypass the returned-event batch. The rule recognizes `this.store`, a local store obtained with `await this.runtime.getStore(...)`, and stores obtained from a `@inject(ChronicleReadModels)` or `@inject(ChronicleRuntime)` parameter, both through a local variable and inline `await` calls. It does not follow helpers, callbacks, or arbitrary stores and does not forbid standalone Arc commands from returning non-event responses. ```ts // Instead of: await this.store.eventLog.append(this.id, new Registered()); return new Registered(); ``` This is an analog of the .NET warning. [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/#what-is-outside-the-batch) explains the batch boundary. # ARCCHR0009 — Secret-looking command field should not be audited > Mark command fields whose names read as secrets when Chronicle does not already mask them. A command field such as `passphrase`, `privateKey`, or `authorizationHeader` may be recorded on the causation chain of every event the command appends. Mark the field `@notAudited()` from `@cratis/arc.chronicle` (or `@pii()` from `@cratis/chronicle/compliance` for personal data). The rule runs only when `@cratis/arc.chronicle` resolves from the linted file, matching the .NET analyzer’s Chronicle-presence gate. It reports decorated command fields and constructor parameter properties with the unmasked words Passphrase, PrivateKey, AccessKey, Pin, Otp, Cvv, Cvc, SecurityCode, and AuthorizationHeader. It follows the .NET analyzer’s whole-word naming heuristic, including pairs of adjacent camel-case words. ```ts import { command } from '@cratis/arc.core'; import { notAudited } from '@cratis/arc.chronicle'; @command() class Register { @notAudited() passphrase = ''; handle() { /* ... */ } } ``` Chronicle already withholds any name containing `password`, `secret`, `token`, `credential`, or `apiKey` (case-insensitive) at runtime. Reporting these would incorrectly claim they reach causation. Fields explicitly annotated as `number`, `boolean`, `Date`, `Guid`, `DateOnly`, `TimeOnly`, or `TimeSpan` are excluded. The syntax-only rule cannot prove the type behind aliases or infer every field’s type, nor does it track computed or inherited fields; review those separately. This is an analog, not a complete data-flow analysis. # ARCCHR0010 — Raw Guid response does not set the event source id > Use eventSourceIdResponse when a keyless command's Guid response is meant to select the event source. For a keyless `@command()`, `tuple(Guid.parse('...'), new Created())` sends the Guid to the caller as an ordinary response. Chronicle still chooses a fallback event source ID. If the ID should identify the new stream, return `tuple(eventSourceIdResponse(id), new Created())` or supply the command key through `@key()`, `getKey()`, or `getEventSourceId()`. This type-checked rule recognizes fundamentals `Guid` values including `Guid.create()`, `Guid.parse(...)`, and variables, beside a direct `new` event decorated with `@eventType()` from `@cratis/chronicle` or `@cratis/chronicle/events`. It does not guess from string responses, event factories, indirect return values, or inherited command keys or event-type decorators; an ordinary Guid response may be intentional. See [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/#return-the-id-to-the-caller). # declared-field: Decorated field is erased > Emit decorated class fields rather than declaring them away. ## What A decorated property uses `declare`. Rule: `arc-core/declared-field`. ## Why `declare` erases the property from the emitted class; use a definite assignment assertion when Arc initializes it. ## Bad ```ts @field(String) declare title: string; ``` ## Good ```ts @field(String) title!: string; ``` # inject-binding: Injection token disagrees with handler > Match typed handle parameters to injected services. ## What The count or provable constructor instance type of a class-valued `@inject` token disagrees with `handle` parameters. Rule: `arc-core/inject-binding` (type comparisons require type information; count checks do not). ## Why Arc binds positional tokens. One extra leading handler parameter can consume an implicit value only when the command defines `provide()`; other mismatches cause a runtime dependency error. Opaque tokens are left to the runtime. ## Bad ```ts @inject(OtherService) handle(tasks: Tasks) {} ``` ## Good ```ts @inject(Tasks) handle(tasks: Tasks) {} ``` # misplaced-decorator: Decorator has no effect > Put query, injection, and authorization decorators on supported declarations. ## What `@query` is not on a static read-model method, `@inject` is not on a command `handle`, or authorization is placed on an unsupported member. Rule: `arc-core/misplaced-decorator`. ## Why Arc does not compile these declarations into an endpoint or injection binding. ## Bad ```ts export class TaskItem { @query() static all() { return []; } } ``` ## Good ```ts @readModel() export class TaskItem { @query() static all() { return []; } } ``` # missing-field: Property is not bound > Mark every model-bound property with @field. ## What A non-`declare` public instance property on `@command()` or `@readModel()` lacks `@field`. Rule: `arc-core/missing-field`. ## Why Unmarked properties are invisible to Arc’s wire schema. Private fields (`private` and `#`) and private-like `_` names are intentionally ignored. ## Bad ```ts @command() export class Register { title!: string; handle() {} } ``` ## Good ```ts @command() export class Register { @field(String) title!: string; handle() {} } ``` # query-argument-name: Match a wire name to a local parameter > Opt-in naming convention for Arc query arguments. Arc binds query descriptors by position. A wire name may intentionally differ from the TypeScript parameter name. If your project prefers matching names, opt into `arc-core/query-argument-name` in the ESLint rules configuration. Neither preset enables this rule. ```js { rules: { 'arc-core/query-argument-name': 'warn' } } ``` The rule reports a literal `argument('wireName', Token)` whose name differs from the corresponding method parameter. It does not change runtime binding. # query-binding: Descriptor disagrees with parameter > Keep explicit query descriptors in positional sync. ## What The number of `@query` descriptors differs from the runtime method parameter count, or a provable constructor token type disagrees with its parameter. Rule: `arc-core/query-binding`. Count checks work without a TypeScript program; type comparisons need one. ## Why Arc binds explicit query arguments and services in declaration order and counts parameters using `method.length` (before the first default or rest parameter). Argument wire names need not match TypeScript parameter names. Bare `@query()` uses inferred metadata and is not checked by this rule. ## Bad ```ts @query(argument('id', TaskId), argument('limit', Number)) static byId(id: TaskId, limit = 10) { return tasks.byId(id); } ``` ## Good ```ts @query(argument('taskId', TaskId)) static byId(id: TaskId) { return tasks.byId(id); } ``` # unexported-artifact: Discovery cannot see a class > Export commands, read models, and validators. ## What A decorated Arc artifact is not exported, including an export at the end of the file. Rule: `arc-core/unexported-artifact`. ## Why File-based discovery inspects exported declarations, not file-local classes. ## Bad ```ts @command() class Register { handle() {} } ``` ## Good ```ts @command() export class Register { handle() {} } ``` # validator-target: Validator has no target > Associate model-bound validators with their exact model. ## What A class extends an Arc validator base but has no `@validator(Target)`. Rule: `arc-core/validator-target`. ## Why Arc only discovers validators associated with an exact target class. The default rule expects `@validator(Target)`. With [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/), Arc reads the validator’s generic target from source; configure the rule option `{ generatedMetadata: true }` for files compiled with that module. ## Bad ```ts export class RegisterValidator extends CommandValidator {} ``` ## Good ```ts @validator(Register) export class RegisterValidator extends CommandValidator {} ``` If your build generates and installs metadata, the undecorated form is also valid: ```js { 'arc-core/validator-target': ['error', { generatedMetadata: true }] } ``` Do not enable that option without the generation and stale-metadata build check: otherwise the class may never register. # Coming from Express and NestJS > Map Express route handlers and NestJS controllers, DTOs, and class-validator rules to Arc commands, read models, and validators, and see what changes and when to stay where you are. If you build Node.js backends today, you probably write one of two shapes: an Express route handler that parses, validates, and answers by hand, or a NestJS controller with DTO classes and `class-validator` decorators. Arc keeps the parts you like (classes, decorators, dependency injection) and removes the parts you repeat: the route, the parser, the status-code mapping, and the client that has to match all of it. ## An Express 5 route handler Express 5 ```typescript import express from 'express'; import { z } from 'zod'; const tasks = new Map(); const RegisterTask = z.object({ id: z.uuid(), title: z.string().min(1, 'A title is required') }); const app = express(); app.use(express.json()); app.post('/api/tasks/register', (request, response) => { const parsed = RegisterTask.safeParse(request.body); if (!parsed.success) { response.status(400).json({ errors: parsed.error.issues.map(issue => ({ path: issue.path, message: issue.message })) }); return; } tasks.set(parsed.data.id, parsed.data.title); response.json({ id: parsed.data.id }); }); app.get('/api/tasks', (_request, response) => { response.json([...tasks].map(([id, title]) => ({ id, title }))); }); app.listen(3000, '127.0.0.1'); ``` ## A NestJS 11 controller This is illustrative NestJS 11 code with `class-validator` and the global `ValidationPipe`: NestJS 11 (illustrative) ```typescript import { Body, Controller, Get, Injectable, Post } from '@nestjs/common'; import { IsNotEmpty, IsUUID, MaxLength } from 'class-validator'; export class RegisterTaskDto { @IsUUID() id!: string; @IsNotEmpty({ message: 'A title is required' }) @MaxLength(100) title!: string; } @Injectable() export class TasksService { readonly #items = new Map(); register(id: string, title: string): void { this.#items.set(id, title); } all() { return [...this.#items].map(([id, title]) => ({ id, title })); } } @Controller('api/tasks') export class TasksController { constructor(private readonly tasks: TasksService) {} @Post('register') register(@Body() dto: RegisterTaskDto) { this.tasks.register(dto.id, dto.title); return { id: dto.id }; } @Get() all() { return this.tasks.all(); } } ``` ## The same thing in Arc The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Tasks/Features/Tasks) splits it by what each piece does: Arc for TypeScript ```typescript @command() export class RegisterTask { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; handle(tasks: Tasks): TaskId { tasks.register(this.id, this.title); return this.id; } } @validator(RegisterTask) export class RegisterTaskValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.title).notEmpty().withMessage('A title is required'); this.ruleFor(command => command.title).maxLength(100).withMessage('A title can have at most 100 characters'); } } @readModel() export class TaskItem { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; @query(service(Tasks)) static allTasks(tasks: Tasks): TaskItem[] { return tasks.all(); } } ``` The imports and the `TaskId`, `TaskTitle`, and `Tasks` types are in the linked sample. The sample’s [generated metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) binds `handle(tasks: Tasks)` to the `Tasks` service; without it, add `@inject(Tasks)`. [Your first command](/arc/backend/typescript/getting-started/your-first-command/) shows every file. ## What changed and why | You wrote | Arc gives you | | --- | --- | | A route string per endpoint | Routes derived from folders and names, [overridable](/arc/backend/typescript/core/endpoint-mapping/) with `@path` | | `express.json()` and `safeParse`, or `@Body()` and `ValidationPipe` | Binding from `@field` declarations; the wrong shape is a 400 `malformedRequest` before your code runs | | Your own error JSON | The Arc result envelope with `validationResults`, the same for every endpoint, which the `@cratis/arc` client understands | | `class-validator` decorators on the DTO | A `CommandValidator` beside the command, plus `ConceptValidator` rules that follow a value everywhere | | A separate “validate only” endpoint, if any | `POST /validate` for every command | | NestJS guards | [`@roles`, `@authorize`, policies](/arc/backend/typescript/authorizing-commands-and-queries/), checked before validation so denied callers never see rule messages | | NestJS providers and constructor injection | [Arc services](/arc/backend/typescript/dependency-injection/) bound by generated metadata or explicit tokens, and a build that rejects missing registrations and captive lifetimes | | Hand-written frontend `fetch` calls and types | [Generated proxies](/arc/backend/typescript/proxy-generation/) with the same routes and the client-safe validation rules | | Polling or a custom WebSocket for live lists | [Observable queries](/arc/backend/typescript/queries/observable-queries/) on the same route | The biggest shift is the split between **commands** that change state and **read models** that serve it. Arc is a CQRS framework: the command does not return the list, and the query does not change anything. You do not have to adopt event sourcing to get that split. Arc does not replace your web framework. You can [mount Arc in Express, Fastify, or Hono](/arc/backend/typescript/hosts/) and keep your existing routes, health checks, and middleware beside it. ## When to stay where you are * **You need what NestJS’s module system gives you**: GraphQL resolvers, microservice transports, a large plugin ecosystem, or a team already fluent in it. Arc covers commands, queries, and live queries over HTTP, not those. * **You need production stability now.** Arc for TypeScript is a source preview: nothing is published to npm, and it does not have full parity with Arc on .NET. Check the [capability reference](/arc/backend/typescript/reference/capabilities/). * **Your endpoints are not command- or query-shaped**, such as file uploads, webhooks with fixed external contracts, or streaming downloads. Keep those as ordinary routes next to Arc. ## Related * [Get started](/arc/backend/typescript/getting-started/) * [Hosting overview](/arc/backend/typescript/overview/) # Commands > Change application state with Arc commands, and find the pages for validation, outcomes, context, operations, and calling commands from code. Registering a task or renaming it should not require a hand-written route, body parser, validation response, and status-code mapping per endpoint. In Arc, a command is a class that carries the input and handles itself. Arc supplies the endpoint, the pipeline, the result envelope, and, through the [proxy generator](/arc/backend/typescript/proxy-generation/), a typed frontend client. Arc is a CQRS framework, not an event store. A command’s `handle()` can call a service, write through a storage integration, or return a value. It does not have to produce an event or use Chronicle. ``` POST Client Arc route Authentication, authorization, validation provide, then handle Response values and operations CommandResult ``` ## A command Features/Tasks/Registration/Registration.ts ```typescript import { field } from '@cratis/fundamentals'; import { command } from '@cratis/arc.core'; import { TaskId } from '../TaskId.js'; import { TaskTitle } from '../TaskTitle.js'; import { Tasks } from '../Tasks.js'; @command() export class RegisterTask { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; handle(tasks: Tasks): TaskId { tasks.register(this.id, this.title); return this.id; } } ``` This is the [Tasks sample command](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts). The sample’s [generated metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) binds the `tasks` parameter to the `Tasks` service; without it, mark `handle()` with `@inject(Tasks)`. [Your first command](/arc/backend/typescript/getting-started/your-first-command/) walks through it with its imports. ## Find your way | Page | Use it when you want to | | --- | --- | | [Model-bound commands](/arc/backend/typescript/commands/model-bound/) | Declare fields, `handle()`, and `provide()`, and control a command’s route | | [Command authorization](/arc/backend/typescript/commands/model-bound/authorization/) | Restrict a command with roles, policies, or anonymous access | | [Command validation](/arc/backend/typescript/commands/command-validation/) | Add rules with `CommandValidator` before `handle()` runs | | [Validation severity filtering](/arc/backend/typescript/commands/validation-severity-filtering/) | Let warnings pass or block, per request | | [Command pipeline](/arc/backend/typescript/commands/command-pipeline/) | Understand the order in which every check runs | | [Command outcomes](/arc/backend/typescript/commands/command-outcomes/) | Return a response, reject, or deny from `provide()` and `handle()` | | [Response value handlers](/arc/backend/typescript/commands/response-value-handlers/) | Process extra return values on the server | | [Command context](/arc/backend/typescript/commands/command-context/) | Read the key, values, and request identity; bind the signal and context | | [Command execution scopes](/arc/backend/typescript/commands/command-execution-scopes/) | Run code around `provide()` and `handle()`, such as a unit of work | | [Command operations](/arc/backend/typescript/commands/operations/) | Declare side effects that Arc executes and compensates | | [Command filters](/arc/backend/typescript/commands/command-filters/) | Validate low-level definitions with `validate` and `filters` | | [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/) | Keep Zod-backed `defineCommand` and `defineQuery` | | [Calling commands from code](/arc/backend/typescript/commands/calling-commands-from-code/) | Run a command or query from a job, a spec, or a Fetch API host | To append events from a command, see the experimental [Chronicle integration](/arc/backend/typescript/chronicle/). # Calling commands from code > Run a command or query from a job, a spec, or a Fetch API host, either through the full HTTP pipeline or directly with an execution context you supply. Sometimes a command has to run without an HTTP client: an import job, a message consumer, an in-process spec, or a host that already speaks the Fetch API. `ArcServer`, available as `app.server`, has entry points for that. They differ in how much of the HTTP pipeline they run and in how much they trust the caller. | Entry point | Runs | Trusts the caller with | | --- | --- | --- | | `handle(request, native?)` | The full HTTP pipeline | Nothing beyond an HTTP client | | `executeCommand(name, input, context)` | Authorization, binding, validators, and the command | The whole execution context, including allowed severity | | `execute(command, context)` | The same, for a decorated command instance | The same | | `validateCommand(name, input, context)` | Authorization, binding, and validators, without `provide` or `handle` | The whole execution context | | `validate(command, context)` | The same, for a decorated command instance | The same | | `performQuery(name, input, context, options?)` | Authorization, binding, validators, and the query | The whole execution context, except allowed severity | ## Run a command and a query directly This example uses the Tasks sample’s classes, added explicitly: ```typescript import { randomUUID } from 'node:crypto'; import { ArcApplication, Severity } from '@cratis/arc.core'; import { Tasks } from './Features/Tasks/Tasks.js'; import { RegisterTask } from './Features/Tasks/Registration/Registration.js'; import { TaskItem } from './Features/Tasks/Listing/Listing.js'; const builder = ArcApplication.createBuilder(); builder.services.addSingleton(Tasks); builder.add(RegisterTask, TaskItem); const app = await builder.build(); const context = { correlationId: randomUUID(), principal: { id: 'import-job', roles: ['system'], isAuthenticated: true }, tenantId: 'acme', signal: AbortSignal.timeout(5_000), allowedSeverity: Severity.Warning }; const result = await app.server.executeCommand('RegisterTask', { id: '1a638f8e-4444-4444-8888-a0b10cdd9977', title: 'Imported' }, context); console.log(result.isSuccess, result.response); const page = await app.server.performQuery('TaskItem.allTasks', {}, context, { paging: { page: 0, pageSize: 10 } }); console.log(page.data, page.paging); await app.dispose(); ``` The first log line is `true 1a638f8e-4444-4444-8888-a0b10cdd9977`; the query returns the imported task with `paging.totalItems: 1`. ## Find the operation by name `executeCommand` and `performQuery` look an operation up by its full name and throw when nothing matches: | Artifact | Full name | | --- | --- | | Model-bound command | Namespace and class name: `Tasks.Registration.RegisterTask` when discovered, `RegisterTask` when added without a namespace | | Model-bound query | Namespace, read-model name, and method: `Tasks.Listing.TaskItem.allTasks` when discovered | | Low-level definition | Namespace and name joined with a dot, such as `Tasks.Create`, or the bare name without a namespace | If you already hold a decorated command instance, `app.server.execute(command, context)` serializes its decorated fields and runs the same pipeline; the registered command name must be unambiguous. Call `validateCommand(name, input, context)` or `validate(command, context)` to check authorization and validation without running the command, like the `/validate` route. `performQuery` takes paging and sorting as its fourth argument. Import `SortDirection` from `@cratis/arc.core`, for example `{ paging: { page: 0, pageSize: 10 }, sorting: { field: 'title', direction: SortDirection.Ascending } }`. ## What a direct call does differently * **You supply the context.** No authentication handler runs and no tenant is resolved. Authorization still runs against the principal and tenant you pass, so pass the real ones. * **Allowed severity is yours to choose for commands.** `executeCommand` uses `context.allowedSeverity` as given, including `Severity.Error`, which lets error-severity validation results pass. HTTP callers cannot do that. Only pass it from code entitled to override business rules. `performQuery` always uses `Severity.Warning`. * **The context is ambient.** Arc freezes a copy of your context and makes it available through `currentContext()` during the call. A nested call gets its own context, and the outer one is restored when it returns. * **Nothing is redacted or logged.** Exception messages and stack traces stay in the result, and the `logger` option is not called. Do not forward the result to an untrusted caller. * **No body limit applies**, because there is no body. ## Send a Fetch API request `app.server.handle(request)` takes a Fetch API `Request` and returns a `Response`, or `null` when the path is not an Arc route: ```typescript const response = await app.server.handle(new Request('http://localhost/api/register-task', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: '1a638f8e-4444-4444-8888-a0b10cdd9977', title: 'Via fetch' }) })); console.log(response?.status, await response?.json()); ``` It runs exactly what a host adapter runs: authentication handlers, tenant resolution, the body limit, the severity cap on `X-Allowed-Severity`, exception redaction, and the `logger`. The request’s `signal` becomes `context.signal`. The optional second argument supplies trusted [native context](/arc/backend/typescript/hosts/native-principal/). ## Related * [Testing](/arc/backend/typescript/testing/), which wraps these entry points in scenarios * [Validation severity filtering](/arc/backend/typescript/commands/validation-severity-filtering/) # Command context > Read a command's key, values, and request identity from CommandContext, bind the context and the abort signal, and load a read model by the command key. `handle()` knows its own fields. Sometimes it also needs to know who is calling, which tenant it runs in, or which entity the command is about. Arc gathers that into one `CommandContext` per execution, shared by handlers, execution scopes, and response value handlers. ## What the context holds | Property | Meaning | | --- | --- | | `operationName` | The declaration’s namespace-qualified name (`fullyQualifiedName` in introspection); optional on manually built contexts | | `command` | The bound command instance | | `key` | The command’s key, resolved once per execution; `undefined` when none | | `values` | Named values from context value providers; lookup is case-insensitive | | `correlationId` | The request’s correlation ID | | `principal` | The authenticated caller, or `undefined` | | `tenantId` | The resolved tenant, or `undefined` | | `allowedSeverity` | The [severity](/arc/backend/typescript/commands/validation-severity-filtering/) that blocks | | `signal` | An `AbortSignal` that aborts when the caller disconnects | Arc-created command contexts have a non-writable, non-configurable `operationName`; the rest of the context remains mutable for values and response handling. Manually built contexts can omit the name. Authorization filters that depend on it must deny an absent name or explicitly handle manual contexts, never infer the operation from the command payload. `currentContext()` returns the execution context from anywhere inside the request, even deep in a service. ## Bind it into a handler ```typescript import { field } from '@cratis/fundamentals'; import { abortSignal, command, commandContext, inject, key, type CommandContext } from '@cratis/arc.core'; @command() export class AssignTask { @field(String) @key() taskId!: string; @field(String) assignee!: string; @inject(commandContext(), abortSignal()) handle(context: CommandContext, signal: AbortSignal): string { signal.throwIfAborted(); return `${context.key} -> ${this.assignee} (${context.values.get('source') as string})`; } } export class RequestSource { provide(): Record { return { source: 'web' }; } } ``` With `builder.services.addScoped(RequestSource)` and `builder.addCommandContextValuesProvider(RequestSource)`, `POST /api/assign-task` with `{ "taskId": "t1", "assignee": "ada" }` answers `"response":"t1 -> ada (web)"`. `commandContext()` and `abortSignal()` are explicit markers: standard decorators cannot see parameter types, so Arc needs them to know what to pass. ## Give a command a key The key identifies what the command is about, such as the task being assigned. No key is inferred from an unmarked `id` field. 1. Scoped `CommandKeyResolver` rules registered with `builder.addCommandKeyResolver(token)` run first. Each has `resolve(command)` returning a string or `undefined`. 2. If none answers, a command that implements `getKey()` uses its result. 3. Otherwise the field marked `@key()` is used; a concept is unwrapped to its primitive value. Arc resolves the key once per execution, after declared authorization and binding but before global authorization filters. Context-value providers and key resolvers are trusted preparation and must not make protected business mutations; they are different from command `provide()`, which runs after authorization and validation. The .NET equivalent of a key rule is `ICanResolveKeyForCommand`. The experimental Chronicle integration also uses the key as the default event source ID; see [Resolving the event source ID](/arc/backend/typescript/chronicle/resolving-event-source-id/). ## Add values to every command A `CommandContextValuesProvider` has `provide(command)` returning a map or record, or a promise of one. Providers run once per command in registration order; a later provider overwrites a name case-insensitively. Register them with `builder.addCommandContextValuesProvider(token)` after registering the token as a service, or with the `commandContextValuesProviders` option. ## Load a read model by key `commandReadModel(Type)` binds a `handle()` or `provide()` parameter to the read model whose identity equals the command key: ```typescript @inject(commandReadModel(TaskView)) handle(task: TaskView): void { /* ... */ } ``` A registered `ReadModelForCommandResolver` must own the type. The [MongoDB](/arc/backend/typescript/mongodb/) and experimental [Chronicle](/arc/backend/typescript/chronicle/read-models/) integrations provide resolvers for the models you configure; you can register your own with `builder.addReadModelForCommandResolver(token)`, implementing `supports(type)` and `find(type, key, context)`. * A missing model becomes a validation failure. `commandReadModel(Type, { optional: true })` passes `null` instead. * Without a usable command key, the command is rejected. * Two resolvers claiming the same type fail rather than one winning by registration order. * Validators do not receive read models this way. ## Related * [Model-bound commands](/arc/backend/typescript/commands/model-bound/) * [Command execution scopes](/arc/backend/typescript/commands/command-execution-scopes/) * [Response value handlers](/arc/backend/typescript/commands/response-value-handlers/) # Command execution scopes > Run code around provide() and handle() of a low-level command, such as a unit of work or timing, and know exactly when each scope begins and completes. Some work belongs around a command rather than inside it: opening and committing a unit of work, or measuring how long the handler took. An execution scope runs `begin` before `provide()` and `handle()`, and `complete` after them, with the result so far. ## Add a scope Scopes are declared on low-level definitions with `scopes`, a list of factories. Arc creates new scope objects for every execution: ```typescript import { defineCommand, type CommandExecutionScope } from '@cratis/arc.core'; import { z } from 'zod'; const timing = (): CommandExecutionScope => { let started = 0; return { begin: () => { started = performance.now(); }, complete: (context, result) => { console.log(context.correlationId, result.isSuccess, performance.now() - started); } }; }; export const archive = defineCommand({ name: 'Archive', namespace: 'Tasks', schema: z.object({ id: z.string() }), scopes: [timing], handle: ({ id }) => ({ id }) }); ``` ## How scopes run 1. Arc creates each scope and records it before calling its `begin`, in list order. If a `begin` throws, no later scope begins, `provide` and `handle` do not run, and the command fails with a 500. 2. `provide` and `handle` run. 3. Every recorded scope completes exactly once, in reverse order, with the result so far. That includes a scope whose `begin` threw, so `complete` must cope with a partly started scope. 4. If any `complete` throws, the command fails with a 500 and the response is removed from the result, even when `handle` succeeded. Scopes do not run for the validation-only route or when authorization or validation fails. Arc does not make a scope transactional: whether `complete` commits or rolls back is your code’s decision, and it can read `result.isSuccess`. When the command returns [operations](/arc/backend/typescript/commands/operations/), a scope that commits business changes must also report explicit commit facts before Arc considers compensation; see [Implementing operations](/arc/backend/typescript/commands/operations/implementing/#report-whether-the-business-change-committed). ## Model-bound commands `@command()` classes do not declare scopes. To wrap every validated model-bound command execution, register a runner with `builder.addCommandExecutionRunner((context, execute) => ...)` or the `commandExecutionRunner` option; it receives the `CommandContext` and a function that runs the rest of the command and returns its `CommandResult`. Integrations use this to set up ambient state for the whole execution. A runner is not a commit participant for operations. ## Related * [Command outcomes](/arc/backend/typescript/commands/command-outcomes/) * [Command pipeline](/arc/backend/typescript/commands/command-pipeline/) * [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/) # Command filters > Authorize or validate all commands with scoped pipeline filters, or validate selected low-level definitions with callbacks. Arc provides two kinds of command filter. Global filters apply to every command, including `@command()` classes and low-level definitions. Per-definition `CommandFilter` callbacks apply only to the `defineCommand` definitions that list them. The two APIs have different result types and ordering semantics; `CommandFilter` remains unchanged. ## Global command filters Use `AuthorizationCommandFilter` for access decisions and `CommandPipelineFilter` for ordinary result fragments. The global ordinary contract has a different name because `CommandFilter` already names the per-definition validation callback. Register separate groups with `addAuthorizationCommandFilter` and `addCommandPipelineFilter`, or discover decorated classes with `@authorizationCommandFilter()` and `@commandPipelineFilter()`. The builder resolves each service from the command’s operation scope; register explicit tokens as scoped or transient services. command-gates.ts ```typescript import { ArcApplication, authorizationCommandFilter, commandPipelineFilter, commandFilterResult, unauthorizedCommandResult, validation, type AuthorizationCommandFilter, type CommandPipelineFilter, type CommandContext, type CommandResult } from '@cratis/arc.core'; @authorizationCommandFilter() export class TenantGate implements AuthorizationCommandFilter { onExecution(context: CommandContext): CommandResult | void { if (!context.tenantId) return unauthorizedCommandResult(context, 'A tenant is required'); } } @commandPipelineFilter() class TitleGate implements CommandPipelineFilter { onExecution(context: CommandContext): CommandResult | void { if (typeof context.command === 'object' && context.command !== null && 'title' in context.command && context.command.title === '') { return commandFilterResult(context, { validationResults: [validation('A title is required', ['title'])] }); } } } export const builder = ArcApplication.createBuilder(); builder.add(TenantGate, TitleGate); ``` A denial answers 403 with `isAuthorized: false` and an optional `authorizationFailureReason`; validation answers 400. Authorization filters always run first regardless of registration order. Within each group registration order is preserved: `ArcOptions` tokens first, explicit `add*` calls next, then decorated classes in `add()` order. Duplicate tokens run only once; a token in both groups fails at build. Each nonempty result fragment merges into the current result; severity is applied to each fragment before deciding whether to stop. Filtered-out warnings cannot bypass later authorization filters. A thrown filter or a fragment with malformed authorization, validation, or exception fields fails the operation with 500; it never permits handling to continue. Omitted fragment fields retain their defaults; supplied authorization must be boolean and validation severity must be a `Severity` value. Both execute and `/validate` run both groups after declared authorization, binding, the bound `authorize` callback, and (for valid input) context-value/key resolution, but before validator or handler dependencies and before scopes, `provide()`, or `handle()`. Context-value providers and key resolvers are trusted preparation and must not perform protected business mutations. Framework-created contexts include a non-writable `operationName` equal to the namespace-qualified declaration identity shown in introspection; manually constructed contexts may omit it. If a filter gates on that name, deny an absent name or explicitly handle the manual case—do not infer it from `context.command`. Malformed input reaches authorization filters without context-value/key resolution. `/validate` runs validators inside an execution runner but never starts execution scopes. Arc checks cancellation before declared authorization and before resolving or invoking each named policy, then before each filter, context-value provider, key resolver, validator dependency or callback, handler dependency, `provide()`, `handle()`, and response-handler service resolution and invocation. It checks again after each awaited resolution. A callback already running settles before Arc stops; any started command scopes still complete and operation services are disposed. A completed command handler’s denial, rejection, or empty return keeps its outcome even when unrelated response handlers are registered. Ordinary values must be classified against the real registered response handlers: values a handler would consume never become client responses. If cancellation prevents a matching handler from starting, the command fails with no response. If an earlier handler already acknowledged an irreversible effect, that effect is not undone: a missing later matching handler still produces `isSuccess: false`, a cancellation exception, and no `response`, with backend recovery and operation outcomes retained. If no later matching handler remains, the acknowledged handler’s successful outcome and client response are preserved. A completed execution runner retains its actual result and diagnostics. An acknowledged Chronicle append remains successful if cancellation only stops its optional projection-completion wait; an actual observer failure still fails the command. The conformance suite pairs command execute and `/validate` filter denial and allow against .NET through Express, Fastify, and Hono. TypeScript fixture traces verify authorization filters precede ordinary filters and validation, and denied requests construct no command validator or execute the handler. Malformed input remains an intentional divergence: TypeScript can deny raw input before reporting malformed input, while .NET rejects it first. ## Per-definition callbacks Low-level definitions made with `defineCommand` and `defineQuery` do not use validator classes. They take a `validate` callback and a list of `filters`, which run in the same pipeline stage as model-bound validators. List a shared callback on each definition that needs it; these callbacks still aggregate all results without short-circuiting. ## Validate and share a filter tasks.ts ```typescript import { defineCommand, validation, type CommandFilter } from '@cratis/arc.core'; import { z } from 'zod'; const shortTitle: CommandFilter<{ title: string }> = ({ title }) => title.length <= 200 ? [] : [validation('A title can have at most 200 characters', ['title'])]; export const rename = defineCommand({ name: 'Rename', namespace: 'Tasks', schema: z.object({ id: z.string(), title: z.string() }), validate: ({ title }) => title.trim() ? [] : [validation('A title is required', ['title'])], filters: [shortTitle], handle: ({ id, title }) => ({ id, title }) }); ``` `validate` runs first, then each filter in order. Every one of them runs, and their results are returned together. A validator or filter may return an array, nothing, or a promise of either. For queries, the filter type is `QueryFilter`. `validation(message, members?, reason?, severity?)` defaults to no members, the reason `rule`, and `Severity.Error`. ## Keep the schema for shape A Zod schema failure produces exactly one result with reason `malformedRequest`, no message a user can act on, and no members. That includes Zod refinements such as `z.string().min(3)`. If a client should show “A title is required” next to the title field, return it from `validate` or a filter. Schemas must convert to JSON Schema Arc converts every schema to JSON Schema at startup. Types without a JSON representation, such as `z.date()` and `.transform(...)`, make startup throw. Send dates as ISO strings with `z.iso.datetime()` and convert them in the handler. ## When a filter throws The caller gets 400 with one result: reason `validatorFailed`, message `Validation failed`, and no members. The exception text is never sent; the original error goes to the `logger` option. When the request was already cancelled, the failure is reported as an exception instead. ## Choose the right extension point Use declared authorization or a named policy for a command-specific access rule, a concept validator for a value used in many commands, and `CommandValidator` for one model-bound command. Use a global authorization filter when a new command must inherit a cross-cutting denial without opting into a policy. Use an ordinary global filter for cross-cutting validation. Command execution runners and scopes wrap execution only; they are not access gates. ## Related * [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/) * [Command validation](/arc/backend/typescript/commands/command-validation/) for model-bound validators * [Command pipeline](/arc/backend/typescript/commands/command-pipeline/) # Command outcomes > Return a response, reject with validation results, or deny from provide() and handle(), and know how Arc tells an outcome from ordinary data. Validation answers whether input follows the rules. Some decisions can only be made later: whether the task exists, or whether the caller may change it once it has been loaded. `provide()` and `handle()` make those decisions with three outcome helpers from `@cratis/arc.core`. ## The helpers | Return | Result | | --- | --- | | A plain value, or `response(value)` | Success; the value becomes the result’s `response` | | `rejected(...results)` | 400 with the results above the allowed severity; later steps do not run | | `denied(reason?)` | 403 with `authorizationFailureReason` set; later steps do not run | | Nothing | Success without a `response` | `provide()` and `handle()` use the same helpers. From `provide()`, a value other than `rejected` or `denied` is passed to `handle()`; see [Model-bound commands](/arc/backend/typescript/commands/model-bound/#prepare-data-in-provide) for a complete example. In a low-level definition, `provide(input, context)` receives the typed input, and `handle(input, context, provided)` receives the provided value typed as `unknown`, so narrow or cast it. If `provide()` or `handle()` throws, the result is a 500. Unless `exposeExceptionDetails` is enabled, the HTTP result carries `An unexpected error occurred` and no stack trace; [Configuration](/arc/backend/typescript/configuration/#errors-and-logging) shows how to log the original. ## Outcomes are recognized by origin, not by shape Arc treats a value as an outcome only when `response`, `rejected`, or `denied` created it. The helpers mark their values with a private symbol, so application data that happens to have a `kind` property, such as `{ kind: 'denied', value: 1 }`, is an ordinary response. To check a value yourself, use `isOutcome(value)`. `rejected()` needs at least one validation result and throws without one, so an empty rejection can never be mistaken for success. That throw becomes a 500. Results passed to `rejected(...)` go through the same [severity filter](/arc/backend/typescript/commands/validation-severity-filtering/) as validators. When nothing above the allowed severity remains: * from `provide()`, `handle()` runs and receives `undefined` as the provided value; * from `handle()`, the command succeeds without a `response`. ## Model several business outcomes When a caller needs to distinguish, for example, a created task from one that already existed, return **one response DTO** with an application-owned status field. Both cases then have the same generated client type and decoder: Features/Tasks/RegisterTask.ts ```typescript import { field } from '@cratis/fundamentals'; import { command, response, type Outcome } from '@cratis/arc.core'; class RegistrationReply { @field(String) status!: string; constructor(status: string) { this.status = status; } } @command({ namespace: 'Tasks' }) export class RegisterTask { @field(String) taskId!: string; handle(): Outcome { return response(new RegistrationReply(this.taskId === 'existing' ? 'alreadyExists' : 'created')); } } ``` This illustration uses a fixture-like condition to select the status; replace it with your actual business decision. The HTTP envelope has a `response` such as `{ "status": "alreadyExists" }` and status 200 in either case. The status is your application data, not an Arc branch tag. You can also return the DTO directly instead of calling `response()`. `Outcome` lets you choose `response(value)`, `rejected(...)`, or `denied(...)` on different paths; it is **not** serialized as a discriminated union. A rejection produces a 400 validation envelope without a response, and a denial produces a 403 authorization envelope without a response. A business error DTO passed to `response()` is an ordinary **200 success response**, not a rejection. No branch index, `kind`, or other discriminator is added to the wire format. The proxy generator accepts alternative paths through aliases, promises, and outcomes only if each has the same client-visible representation. Boolean, enum, and literal-union responses retain their full client type and primitive metadata token, including when nullable. Query results with these types also receive primitive element metadata. It filters out values consumed by server-side handlers. It rejects different DTO constructors or cardinalities instead of choosing an arbitrary decoder; the diagnostic recommends one response DTO with an application-owned status field. This intentionally differs from Arc on .NET 22.45.0: .NET executes `OneOf<...>` and `Result` by unwrapping the selected value, but its generator picks a single response type and may misdecode another business branch. A .NET `Result` error DTO is returned as a **200 response**, not as a rejection. Do not rely on a client-visible union unless the client and generator both support its discriminant. ## Return more than one value `tuple(first, second, ...)` returns several **simultaneous** values from `handle()`; it does not express alternative outcomes. At most one of them may be the client response; every other value must be consumed on the server by a [response value handler](/arc/backend/typescript/commands/response-value-handlers/), or the command fails. Ordinary arrays stay ordinary response values. A returned [command operation](/arc/backend/typescript/commands/operations/) is one such server-side value. Each alternative path is checked independently: a tuple path may contain server-handled values and one response. ## Related * [Command pipeline](/arc/backend/typescript/commands/command-pipeline/) * [Command execution scopes](/arc/backend/typescript/commands/command-execution-scopes/) * [Command validation](/arc/backend/typescript/commands/command-validation/) # Command pipeline > The fixed order in which Arc authenticates, resolves the tenant, authorizes, validates, and runs a command, and what each failure answers. Every command passes through the same stages in the same order, whichever host delivered it. Knowing that order explains why a denied caller never sees rule messages and why `/validate` never touches your handler. ``` Failed, or no user for a protected operation Not valid JSON Not met false Denied No Blocking result Results above the allowed severity Yes No Request for an Arc route Authentication handlers 401 Resolve tenant Read and parse the body 400 malformedRequest Declared authorization 403 Bind fields or schema authorize callback if bound 403 Resolve command key and context values Global authorization filters 403 without validation details Input shape valid 400 malformedRequest Global ordinary filters 400 or 500 Validators, then per-definition filters 400 with every result Validation-only route 200, no execution Begin scopes, provide, handle, response values and operations, complete scopes ``` ## The stages | Step | You configure it with | When it fails | | --- | --- | --- | | 1. Authentication | `authentication` handlers, or a native principal | 401 | | 2. Tenant | `tenancy.httpHeader`, `tenancy.sources`, or `tenancy.resolve` | Does not reject by default; `tenancy.required` answers 400 and a membership check 403 | | 3. Read the input | Nothing | 400 `malformedRequest` | | 4. Declared authorization | `@roles`, `@authorize`, `@allowAnonymous`, or `authorization` | 403 | | 5. Bind the input | `@field` declarations, or a Zod `schema` | A wrong shape is held until global authorization finishes, then 400 `malformedRequest` | | 6. Per-request authorization | `authorize(input, context)` on a successfully bound low-level definition | 403 | | 7. Resolve context | Scoped context-value providers and key resolvers on valid input | A failure stops execution | | 8. Global authorization filters | Scoped `AuthorizationCommandFilter` services | 403 for denial; a thrown filter fails closed | | 9. Global ordinary filters | Scoped `CommandPipelineFilter` services | 400 for validation, 500 for exceptions | | 10. Validation | Validators, concept validators, then `validate` and per-definition `filters` | 400 with every result collected | | 11. Execute | Execution scopes, `provide()`, `handle()`, response value handlers, operations | 400, 403, or 500 from the outcome | ## Consequences that are easy to miss * Global filters short-circuit at the first fragment that remains unsuccessful after severity filtering; existing per-definition callbacks still collect every result. * `POST /validate` stops after step 10. `provide()`, `handle()`, and execution scopes never run; execution runners wrap the validation stage. * Unparseable JSON is rejected in step 3. A parsed body with the wrong shape reaches global authorization before reporting 400. On binding failure `context.command` is raw input, and context value providers and key resolvers do not run. A denial never discloses validation results. * Step 1 answers 401 only when at least one authentication handler is configured. With none, nobody is authenticated and a protected operation answers 403 in step 4. * On valid input, context-value providers and key resolvers run before context-aware authorization filters so those filters can inspect the resolved key and values, as in .NET. These providers and resolvers are trusted preparation: they must not make protected business mutations. They are not command `provide()`, which runs only during execution. Do not assume that no application code runs before authorization; declared authorization and the bound `authorize` callback precede preparation, but global authorization filters follow it. * Filter services are resolved in the operation scope. Validator dependencies are constructed after global filters, including on `/validate`; handler dependencies are constructed only after validation succeeds. A missing dependency reports reason `dependencyUnavailable`. * A validator that throws produces 400 with reason `validatorFailed` and message `Validation failed`; the exception text is never sent, and the error goes to the configured logger. * Cancellation is an unconditional admission check before each stage and after each awaited resolution, even after an acknowledged commit: declared authorization and each named policy resolution/invocation, filters, validators, dependency resolution, `provide()`, `handle()`, each response-handler service resolution and invocation, and query `perform()` and `render()`. An in-flight callback is allowed to finish; no later stage starts. A completed command handler’s `denied(...)`, `rejected(...)`, or empty result is preserved without starting response handlers, even when unrelated handlers are registered. Ordinary returned values are classified against the actual registered handlers: handled values never become client responses, while a plain value that none handle can be the client response. Cancellation before a matching response handler starts produces a failure with no response, not a successful response containing its unhandled effect. If a response handler has acknowledged an irreversible effect, cancellation after it completes keeps success and the client response only when no later matching handler is waiting. If a later matching handler cannot start, the result has `isSuccess: false`, a cancellation exception, and no `response`; any already acknowledged effect remains committed, and backend recovery/operation-outcome diagnostics remain available. A completed execution runner likewise retains its actual result and diagnostics. Integrations must call `acknowledgeCommandCommit(context)` only after an authoritative acknowledgment (Chronicle does this after the append). Cancellation of the optional Chronicle projection-completion wait after an acknowledged append leaves the append successful with its response; the read model might not yet be updated. A genuine observer failure still fails the command. ## Status codes The result envelope and status code follow the [Arc HTTP contract](/arc/http-contract/). A result that fails at any step never carries a `response` value. Unless `exposeExceptionDetails` is enabled, exception messages and stack traces are replaced before serialization; the correlation ID stays. ## Related * [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/) * [Command outcomes](/arc/backend/typescript/commands/command-outcomes/) * [Query pipeline](/arc/backend/typescript/queries/query-pipeline/) # Command validation > Give callers field-specific messages with CommandValidator rules, choose the phase where each rejection belongs, and read stored state in a rule with readModelForValidation. A task title arrives as a string, but it must not be blank, and a rename to the same title is pointless. If those checks live inside `handle()`, a form cannot ask about them before the user presses Save, and every handler grows its own error format. Arc gives rules a place of their own: a validator runs before `handle()`, answers with a message for the exact field, and also runs on the command’s `/validate` route, so a frontend can check input without changing anything. ## Add a command rule The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts) keeps its validator in the same file as `RegisterTask`, so the imports it needs are the Arc ones: Features/Tasks/Registration/Registration.ts (excerpt) ```typescript import { command, CommandValidator, validator } from '@cratis/arc.core'; // RegisterTask is declared above in the same file. @validator(RegisterTask) export class RegisterTaskValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.title).notEmpty().withMessage('A title is required'); this.ruleFor(command => command.title).maxLength(100).withMessage('A title can have at most 100 characters'); } } ``` `@validator(RegisterTask)` supplies the runtime target that TypeScript’s erased generic cannot. It does not register anything globally: `builder.discover(...)` picks it up from the folder, or you pass it to `builder.add(RegisterTask, RegisterTaskValidator)`. One validator may target each exact model class; a duplicate target fails at build. Send `{ "id": "", "title": "" }` to `POST /api/tasks/registration/register-task/validate` on the running sample. The answer is 400 with: ```json {"severity":3,"message":"A title is required","members":["title"],"reason":"rule"} ``` Here is what happened. Arc bound the body to a `RegisterTask`, ran every validator for the command and for the concepts on its fields, and collected all their results. A failure does not stop the other rules, so a form can show every problem at once. Because a result was above the allowed severity, Arc answered 400 and never reached `handle()`. A type mismatch, a missing required field, or malformed JSON fails earlier with `malformedRequest` and no rule message. ## Construct validation results Use `ValidationResult.information`, `ValidationResult.warning`, or `ValidationResult.error` when a low-level validator or a command outcome needs a result at a specific severity. The optional second argument carries members and rule-author-owned state; `reason` defaults to `rule`. Use `reasonDetail` to identify a specific rejection without parsing its message. The existing `validation(message, members?, reason?, severity?)` helper remains available. validation-results.ts ```typescript import { defineCommand, ValidationResult } from '@cratis/arc.core'; import { z } from 'zod'; export const save = defineCommand({ name: 'Save', schema: z.object({ title: z.string() }), validate: ({ title }) => title.trim() ? [] : [ ValidationResult.error('A title is required', { members: ['title'], state: { attempted: title }, reasonDetail: 'TitleRequired' }) ], handle: () => undefined }); ``` `state` and `reasonDetail` reach the HTTP validation result for commands and queries when supplied; absent optional fields are omitted. The installed `@cratis/arc` client models these fields and the open-ended string `reason` on its validation results. Treat `state` as application-owned, JSON-serializable data: responses are serialized with `JSON.stringify`, so a `Map` or `Set` becomes `{}`, a class instance arrives as a plain object without its prototype (or as its `toJSON()` output), and a `bigint` fails serialization. ## Choose where to reject Validators are one of several places a command can say no. Each place sees different information and runs at a different moment, so the right one depends on what the decision needs: | The decision depends on | Put it in | Runs on `/validate` | Caller sees | | --- | --- | --- | --- | | The request’s shape: types, required and optional fields | `@field` declarations | Yes | 400 `malformedRequest`, no message or members | | Who is calling | `@roles`, `@authorize`, or a policy | Yes | 401 or 403 | | One value, wherever it appears (a title format) | A [concept validator](/arc/backend/typescript/concepts/#validate-a-concept-everywhere) | Yes | 400 with your message and member | | The command’s own fields, or a service | A `CommandValidator` rule | Yes | 400 with your message and member | | Stored state for the entity the command is about | A `CommandValidator` rule that calls `readModelForValidation` | Yes | 400 with your message and member | | Data you load anyway to do the work (the task must exist, the caller must own it) | `provide()` returning `rejected(...)` or `denied(...)` | No | 400 or 403 | | A decision only the handler can make | `handle()` returning `rejected(...)` or `denied(...)` | No | 400 or 403 | Two rules of thumb pick the row: * **Reject as early as the information allows.** Everything down to the readModelForValidation row runs on `/validate`, so a form gets the message before submitting. `provide()` and `handle()` run only on execution. * **Keep access control out of validators.** A trusted direct caller can lower the blocking severity, which lets validation results through; nothing lowers authorization or `denied(...)`. See [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/). A check against stored state tells you what was true when it ran. Another request can change that state before `handle()` runs, so enforce a rule that must hold under concurrency at the storage boundary that performs the change, not only in a validator. [Command outcomes](/arc/backend/typescript/commands/command-outcomes/) covers `rejected` and `denied`; [Model-bound commands](/arc/backend/typescript/commands/model-bound/#prepare-data-in-provide) shows `provide()`. ## Read stored state in a rule “The task already has this title” needs the stored task. A rule can read the read model that belongs to the command’s key with `readModelForValidation(Type)` from `@cratis/arc.core`: RenameTask.ts ```typescript import { field } from '@cratis/fundamentals'; import { command, CommandValidator, key, readModelForValidation, validator } from '@cratis/arc.core'; import { TaskView } from './TaskView.js'; @command() export class RenameTask { @field(String) @key() id!: string; @field(String) title!: string; handle(): void { // Rename the task in your storage. } } @validator(RenameTask) export class RenameTaskValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.title).mustAsync(async title => { const current = await readModelForValidation(TaskView, { optional: true }); return current === null || current.title !== title; }).withMessage('The task already has this title'); } } ``` `TaskView` is your read model, with at least a string `title` field. Arc does not load it itself; a registered read-model resolver does. The [MongoDB](/arc/backend/typescript/mongodb/) and experimental [Chronicle](/arc/backend/typescript/chronicle/read-models/) integrations register resolvers for the models you configure, and `builder.addReadModelForCommandResolver(token)` adds your own, as described in [Command context](/arc/backend/typescript/commands/command-context/#load-a-read-model-by-key). What happens when the rule runs: * Arc resolves the command key from the `@key()` field and asks the resolver that owns `TaskView` for that key. The resolver also receives the command context, with the caller’s tenant, so it can scope the lookup. * `{ optional: true }` returns `null` when nothing is stored, so the rule decides what absence means. Here, a task that does not exist yet has no title to repeat. Without `optional`, a missing model makes the rule throw, and the caller gets reason `validatorFailed` instead of your message. * Renaming task `t-1` from `Old` to `Old` answers 400 with `The task already has this title` for `title`, on both the execute and `/validate` routes. Renaming it to `New` succeeds. `readModelForValidation` is the supported API for reading command state in a validator of a model-bound command. It runs only during validation; called outside that scope, it throws `Command read models can only be resolved during command validation`. Validators do not receive loaded command read models through their constructors. Validation, `provide()`, and `handle()` reuse the same resolved instance within one command when they request the same model type. In `provide()` and `handle()`, use `commandReadModel(Type)` rather than `readModelForValidation`. Separate commands have separate caches, including concurrent commands with the same key or commands in different tenants. Resolver failures are not treated as missing state: a failed lookup produces `validatorFailed`, while cancellation stops the command. ## Rule vocabulary | Rules | Where they run | | --- | --- | | `notNull`, `notEmpty`, `minLength`, `maxLength`, `length`, `emailAddress`, `phone`, `url`, `matches`, `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual` | Server; literal, unconditional uses are also emitted into [generated proxies](/arc/backend/typescript/proxy-generation/validation/) | | `empty`, `null`, `equal`, `notEqual`, `inclusiveBetween`, `exclusiveBetween`, `must`, `mustAsync` | Server only | `withMessage`, `withSeverity`, and `withState` decorate the most recent rule; state is included in the HTTP result when it is not null. * Rules on concept members receive the unwrapped primitive: `TaskTitle` rules accept a `string`. * String rules are only available for strings, and comparison rules only for numbers and temporal values. * `notEmpty()` rejects `Guid.empty`; `equal()` and `notEqual()` compare GUID and temporal values by value. * Email, phone, URL, regular-expression, default-message, and Unicode-length behavior is not guaranteed to match FluentValidation or the JavaScript client exactly. For rules that matter across runtimes, write an explicit message and test the inputs you accept. ## Conditions, services, and asynchronous rules `when(predicate)` and `unless(predicate)` condition **every rule on the chain** by default. Pass `ApplyConditionTo.CurrentValidator` as the second argument to condition only the latest rule. `must` and `mustAsync` receive `(value, model, signal)`; pass the signal to cancelable I/O instead of starting work that outlives the request. Conditions and asynchronous rules run only on the server. A validator may declare ordinary service constructor dependencies with `@injectable(Service)` or `static inject = [Service] as const`. Register the service with `builder.services`. Arc preflights the dependencies and constructs each validator once during build, which catches invalid selectors before any request. It then resolves fresh validators and services in each execution scope, subject to the service’s registered lifetime. During normal command execution, validators are constructed in an execution scope that carries the tenant. Their constructors also run once during build preflight without a command or tenant, so they must be safe to construct in that scope. Use them to configure rules and retain service dependencies, not to load command state or perform tenant-specific I/O. Read state inside the asynchronous rule, as in the example above; build preflight constructs validators but does not evaluate those rules. This intentionally differs from Arc for .NET, where a validator can receive a command read model through constructor injection. In TypeScript, keep ordinary service injection in the constructor and use `readModelForValidation(Type)` in the rule for command-keyed state. The [validation compatibility decision](https://github.com/Cratis/Arc.TypeScript/blob/main/decisions/0002-keep-validator-command-state-access-asynchronous.md) records this boundary. A validator that throws, or whose dependency cannot be resolved, never reports success. The caller gets 400 with reason `validatorFailed` or `dependencyUnavailable`, no exception text, and the error goes to the configured logger. ## Low-level definitions `defineCommand` and `defineQuery` do not use validator classes; they keep their `validate` and `filters` callbacks, which run in the same pipeline stage. See [Command filters](/arc/backend/typescript/commands/command-filters/). ## Recap Shape belongs on `@field`, a value’s own rules on its concept, a command’s rules in a `CommandValidator`, and access control in authorization. A rule that needs stored state reads it with `readModelForValidation`; a decision that needs the loaded data belongs in `provide()`. Everything up to validation also answers on `/validate`, which is what lets a form speak up before the user submits. ## Next step [Validation severity filtering](/arc/backend/typescript/commands/validation-severity-filtering/) explains which severities block and how a caller can let warnings through. To prove a rule in a spec, see [Testing commands](/arc/backend/typescript/testing/commands/). # Low-level definitions > Keep Zod-backed defineCommand, defineQuery, and defineObservableQuery definitions when decorators do not fit, on the same pipelines as model-bound artifacts. Model-bound classes are the default. Keep `defineCommand`, `defineQuery`, and `defineObservableQuery` when you already have Zod schemas, need explicit `validate` and `filters` callbacks, or integrate code that cannot use decorators. The definitions run through the **same pipelines** as model-bound artifacts. ```typescript import { ArcServer, defineCommand, defineQuery } from '@cratis/arc.core'; import { z } from 'zod'; const tasks = new Map(); const create = defineCommand({ name: 'Create', namespace: 'Tasks', schema: z.object({ id: z.string(), title: z.string() }), handle: ({ id, title }) => { tasks.set(id, title); return id; } }); const list = defineQuery({ name: 'List', namespace: 'Tasks', schema: z.object({}), perform: () => [...tasks].map(([id, title]) => ({ id, title })) }); const server = new ArcServer({ commands: [create], queries: [list] }); ``` This registers two operations in memory. Host `server` with [`runArc`](/arc/backend/typescript/core/#host-a-low-level-server) or a [host adapter](/arc/backend/typescript/hosts/) to serve `POST /api/tasks/create` and `GET /api/tasks/list`. You can also pass the definitions to `ArcApplication.createBuilder({ commands: [create], queries: [list] })` next to model-bound artifacts. ## What differs from model-bound artifacts * The Zod schema, not field decorators, controls the input and its JSON Schema. Schemas must convert to JSON Schema; see [Command filters](/arc/backend/typescript/commands/command-filters/#keep-the-schema-for-shape). * Services are declared with `handlerDependencies` and `validatorDependencies` and resolved with `currentServices()`; see [Dependency injection](/arc/backend/typescript/dependency-injection/#low-level-services). * Validation uses `validate` and `filters`; see [Command filters](/arc/backend/typescript/commands/command-filters/). * Commands can declare [execution scopes](/arc/backend/typescript/commands/command-execution-scopes/). * Authorization is a property: `authorization: { roles: ['editor'] }`, plus an optional per-request `authorize(input, context)`; see [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/). * Client generation uses an explicit `clientOutput` contract and the [low-level manifest](/arc/backend/typescript/proxy-generation/low-level-manifest/). Do not mix both representations for the **same** operation: duplicate names or routes are rejected at startup. The full list of definition fields is in [Configuration](/arc/backend/typescript/configuration/#low-level-definition-fields). ## Related * [Query arguments](/arc/backend/typescript/queries/model-bound/query-arguments/), which apply to both kinds of query * [Observable queries](/arc/backend/typescript/queries/observable-queries/) for `defineObservableQuery` # Model-bound commands > Declare a command class with typed fields and a handle() method, prepare data in provide(), bind services and built-in values, and control its route. A command expresses a change. You declare it as a class: decorated fields are the input, and `handle()` is the work. Arc binds the fields before calling `handle()`, so your code works with typed values rather than raw JSON. ## Declare fields and a handler The [Tasks sample command](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts) is a complete, compiled example: ```typescript import { field } from '@cratis/fundamentals'; import { command } from '@cratis/arc.core'; import { TaskId } from '../TaskId.js'; import { TaskTitle } from '../TaskTitle.js'; import { Tasks } from '../Tasks.js'; @command() export class RegisterTask { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; handle(tasks: Tasks): TaskId { tasks.register(this.id, this.title); return this.id; } } ``` * `@command()` from `@cratis/arc.core` marks the class. It must have a public instance `handle()`, which may be inherited. * `@field(Type)` from `@cratis/fundamentals` declares each input field and its wire type. Every field is required unless you add `@optional()`, `@nullable()`, or `@defaultValue(value)`. [Concepts](/arc/backend/typescript/concepts/) lists the supported types. * [Generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) binds the `Tasks` parameter by type. Without it, use `@inject(Tasks)`. See [Dependency injection](/arc/backend/typescript/dependency-injection/). `handle()` can return a plain value or a promise. The value becomes the result’s `response`; concepts are encoded as their primitive value. To reject, deny, or return several values, see [Command outcomes](/arc/backend/typescript/commands/command-outcomes/). ## Prepare data in provide() `provide()` is optional. It runs after validation and before `handle()`, on the same command instance, and its value becomes the **first** `handle()` argument, before injected services: ```typescript @command() export class RenameTask { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; @inject(Tasks) provide(tasks: Tasks) { const task = tasks.byId(this.id); return task ?? rejected(validation('The task does not exist', ['id'], 'notFound')); } handle(task: TaskItem): void { task.title = this.title; } } ``` This excerpt assumes the Tasks sample’s `TaskId`, `TaskTitle`, `TaskItem`, and `Tasks`, and imports `rejected` and `validation` from `@cratis/arc.core`. Returning `rejected(...)` or `denied(...)` from `provide()` stops execution; `handle()` never runs. For several prepared values, or values mixed with services, return `tuple(...)` from `provide()` and mark each `handle()` parameter with `@inject(provided(ValueType), commandContext(), provided(OtherType))`. Arc matches the marked values by runtime type, in declared parameter order. Unmarked preparation still uses the first parameter; Arc never silently matches a prepared value to a service. ## Bind built-in values | Marker | Binds | | --- | --- | | `abortSignal()` | The request’s `AbortSignal` | | `commandContext()` | The current [`CommandContext`](/arc/backend/typescript/commands/command-context/) | | `provided(Type)` | A value returned from `provide()`, matched by runtime type | | `commandReadModel(Type)` | A read model loaded by the command’s key; see [Command context](/arc/backend/typescript/commands/command-context/#load-a-read-model-by-key) | Use them in `@inject(...)` on `handle()` or `provide()`, for example `@inject(abortSignal(), commandContext())`. Without generated metadata, standard decorators need these explicit markers because TypeScript erases parameter types. ## Route and namespace The route combines the discovery namespace and the class name: `Tasks.Registration.RegisterTask` is served at `POST /api/tasks/registration/register-task`, and `POST /validate` runs authorization and validation without calling `provide()` or `handle()`. Use `@command({ namespace: 'Tasks.Registration' })` to fix the namespace, and `@path('/api/custom-path')` when a folder move must not change the public URL. See [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/). ## Build-time checks Arc rejects declarations that would silently do nothing: `@inject` or authorization decorators on `provide()`, authorization on `handle()`, and a class added with `add()` that has no Arc decorator. The [ESLint rules](/arc/backend/typescript/code-analysis/) catch many of the same mistakes in the editor. ## Related * [Command authorization](/arc/backend/typescript/commands/model-bound/authorization/) * [Command validation](/arc/backend/typescript/commands/command-validation/) * [Command context](/arc/backend/typescript/commands/command-context/) * [Testing commands](/arc/backend/typescript/testing/commands/) # Command authorization > Restrict a model-bound command with @roles, @authorize, and @allowAnonymous, and know how stacked declarations combine. Put access rules on the command class. Arc checks them before it validates the input, so a caller who may not run the command never sees its rule messages. ```typescript import { field } from '@cratis/fundamentals'; import { authorize, command, roles } from '@cratis/arc.core'; @command() @roles('Editor', 'Admin') export class ArchiveTask { @field(String) id!: string; handle(): void { /* archive the task */ } } @command() @authorize({ policy: 'Finance' }) export class ApproveBudget { @field(String) id!: string; handle(): void { /* approve */ } } ``` | Decorator | Requirement | | --- | --- | | None | Everyone, including anonymous callers | | `@authorize()` | Any authenticated caller | | `@roles('Editor', 'Admin')` | An authenticated caller with at least one of the roles | | `@authorize({ policy: 'Finance', roles: [...] })` | A registered [policy](/arc/backend/typescript/core/authorization/) accepts the caller, plus any listed role | | `@authorize({ schemes: ['Verified'] })` | A principal authenticated by the named [scheme](/arc/backend/typescript/core/authorization/#select-an-authentication-scheme) | | `@allowAnonymous()` | Everyone, stated explicitly | ## Combine declarations * Roles within one declaration are alternatives: `@roles('Editor', 'Admin')` allows either. * Stacked decorators are separate requirements, and **all** must pass: `@authorize({ policy: 'Finance' })` plus `@roles('Admin')` requires both. * `@allowAnonymous()` cannot share a declaration with an authenticated requirement; startup rejects the combination. ## Where decorators may go Authorization belongs on the command class. `@authorize`, `@roles`, and `@allowAnonymous` on `handle()`, `provide()`, or any other command method fail the build instead of being ignored. Arc on .NET 22.45.0 evaluates only the command type and ignores authorization attributes on command methods; this build-time rejection is a deliberate TypeScript difference. ## When the answer depends on the input A decorator cannot see which task the caller wants to archive. For per-input or per-tenant decisions, the low-level `authorize(input, context)` callback runs after the schema and before validation; see [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/#decide-per-request). For a model-bound command, a check that needs stored data can deny from `provide()` with `denied(reason)`; see [Command outcomes](/arc/backend/typescript/commands/command-outcomes/). ## Related * [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/) * [Authentication](/arc/backend/typescript/core/authentication/) # Command operations > Return server-side operations from a command so Arc executes them in order and attempts compensation when the change did not commit. A command that sends a notification after saving has two ways to fail: the save, and the send. Written inline, the handler has to catch, undo, and report each combination itself. A **command operation** lets `handle()` declare *what* should happen, and Arc coordinates the execution and its recovery. ## Declare and return an operation This example records calls in memory to make the order visible; replace the recorder with a real service before using it for business effects. ```typescript import { field } from '@cratis/fundamentals'; import { CommandOperation, command, tuple } from '@cratis/arc.core'; const calls: string[] = []; class Notify extends CommandOperation { execute(signal: AbortSignal): void { if (signal.aborted) throw signal.reason; calls.push('notify'); } compensate(_failure: unknown, signal: AbortSignal): void { if (signal.aborted) throw signal.reason; calls.push('undo notification'); } } @command() class SendNotification { @field(String) id!: string; handle() { return tuple({ id: this.id }, new Notify()); } } ``` Executed through Arc, `POST /api/send-notification` with `{ "id": "visible" }` answers `"response":{"id":"visible"}`, and `calls` holds `notify`. The client never receives the operation. A direct call to `handle()` in your own code only returns the declaration; nothing executes. ## How Arc runs operations 1. Arc validates every declaration and resolves every service dependency before entering the first operation. 2. It runs the operations in order and records each invocation before calling `execute`. 3. On failure it completes execution scopes first, then considers compensation in reverse order, including the operation that threw. 4. Compensation uses a separate, shared cooperative timeout, `commandCompensationTimeoutMs` (30,000 ms by default), not the request’s cancelled signal. Arc cannot stop a callback that ignores its signal; a compensator that finishes after the budget is reported as `BudgetExpired`. Use `operations(first, second)` for an explicit batch. A plain array is **not** a batch. ## Know the limits Arc provides no distributed transaction, automatic retries, durable recovery journal, or crash recovery. A successful compensation callback is not proof that an external change was undone. Operations that need those guarantees need their own durable workflow. Returning Chronicle events together with operations is rejected before either effect runs. ## Continue * [Implementing operations](/arc/backend/typescript/commands/operations/implementing/): dependencies, commit participants, and the flat command boundary. * [Operations reference](/arc/backend/typescript/commands/operations/reference/): types, dispositions, outcomes, and test assertions. * [Command outcomes](/arc/backend/typescript/commands/command-outcomes/) for ordinary responses and rejection without operations. # Implementing operations > Give operations service dependencies, report whether the business change committed, and stay within the flat command boundary Arc can recover. An operation is a small class extending `CommandOperation`. This page covers what you need beyond the basic example in [Command operations](/arc/backend/typescript/commands/operations/): services, commit facts, and the boundary Arc can coordinate. ## Resolve services Declare `executeDependencies` and `compensateDependencies` as token arrays on the operation. `execute` receives the execution signal followed by the resolved execute dependencies; `compensate` receives the failure and a cleanup signal followed by the resolved compensate dependencies: ```typescript class Notify extends CommandOperation { readonly executeDependencies = [Mailer] as const; readonly compensateDependencies = [Mailer] as const; constructor(private readonly to: string) { super(); } async execute(signal: AbortSignal, mailer: Mailer): Promise { await mailer.send(this.to, signal); } async compensate(_failure: unknown, signal: AbortSignal, mailer: Mailer): Promise { await mailer.retract(this.to, signal); } } ``` This excerpt assumes an application `Mailer` class with `send` and `retract` methods, registered with `builder.services.addSingleton(Mailer)`, and `CommandOperation` imported from `@cratis/arc.core`. Both method signatures must have exactly the declared number of parameters; default and rest parameters are not supported, because they do not give a reliable parameter count. Arc resolves both sets before starting any operation. Do not capture a request-scoped service in the operation’s constructor. ## Report whether the business change committed Compensation is only safe when Arc knows the business change did not commit. Without a participating scope, Arc treats the batch as `NoCommit`. When a low-level command has [execution scopes](/arc/backend/typescript/commands/command-execution-scopes/), each scope must declare its role by implementing `CommandOperationExecutionScope`: * Set `isCommitParticipant: true` on the one scope that commits business changes, and report `getCommitDisposition(context)` from the underlying storage boundary, including after `complete()` throws. At most one participant may coordinate a deferred commit. * Set `isCommitParticipant: false` on every other scope. That promises it does not commit business changes. * Arc rejects scopes that do not declare either, rather than treating them as safe. | Disposition | What Arc does on failure | | --- | --- | | `NoCommit`, `NotCommitted` | Attempts compensation | | `Committed` | Suppresses compensation | | `Unknown`, `Mixed` | Never turns it into success or an automatic reversal; recovery is indeterminate | Do not use `result.isSuccess` to guess commitment: a network error after a write may mean the server committed but the acknowledgment was lost. ## Stay within a flat boundary * A nested child command cannot return operations. * A parent that has attempted a nested command cannot return operations, even if it ignored the child’s failed result. * A nested Arc command inside an operation is not supported. Return another declaration instead. ## Inspect what happened Direct calls expose `CommandResult.recovery` and `CommandResult.operationOutcomes`, which describe what the server observed, including missing or timed-out compensation. They are intentionally absent from HTTP JSON. Avoid returning secrets or operation payloads in the client response. The [reference](/arc/backend/typescript/commands/operations/reference/) lists their fields. ## Related * [Operations reference](/arc/backend/typescript/commands/operations/reference/) * [Testing commands](/arc/backend/typescript/testing/commands/#assert-operations) # Operations reference > The CommandOperation contract, commit dispositions, recovery summaries, operation outcomes, the compensation option, and the scenario assertions for operations. All types are exported from `@cratis/arc.core`; the assertions come from `@cratis/arc.testing`. ## CommandOperation | Member | Required | Meaning | | --- | --- | --- | | `execute(signal, ...dependencies)` | Yes | Perform the effect; may return a promise | | `compensate(failure, signal, ...dependencies)` | No | Undo the effect after a known uncommitted failure | | `executeDependencies` | No | Tokens resolved and passed to `execute`, in order | | `compensateDependencies` | No | Tokens resolved and passed to `compensate`, in order | `operations(first, second, ...)` creates an explicit `CommandOperations` batch. Return operations from `handle()`, alone or inside `tuple(...)`. ## CommandOperationExecutionScope Extends `CommandExecutionScope` (`begin`, `complete`) with: | Member | Meaning | | --- | --- | | `isCommitParticipant` | `true` for the one scope that commits business changes; `false` promises the scope commits nothing | | `getCommitDisposition(context)` | The authoritative `CommandCommitDisposition`, also after `complete()` throws | ## CommandCommitDisposition | Value | Meaning | Compensation | | --- | --- | --- | | `NoCommit` | No participant commits business changes | Attempted | | `NotCommitted` | The participant knows nothing committed | Attempted | | `Committed` | The participant committed | Suppressed | | `Unknown` | The participant cannot tell | Not attempted; recovery is indeterminate | | `Mixed` | Some changes committed and some did not | Not attempted; recovery is indeterminate | ## CommandRecoverySummary (`result.recovery`) | Field | Meaning | | --- | --- | | `commitDisposition` | The `CommandCommitDisposition` Arc used | | `status` | `CommandRecoveryStatus`: `NotNeeded`, `Completed`, `Incomplete`, `Suppressed`, or `Indeterminate` | | `startedCount` | Operations entered | | `completedCount` | Operations whose `execute` finished | | `compensatedCount` | Compensations that finished | | `failedCompensationCount` | Compensations that threw | | `uncompensatedCount` | Entered operations left without completed compensation | ## CommandOperationOutcome (`result.operationOutcomes`) | Field | Meaning | | --- | --- | | `invocationIndex` | Position in the executed order | | `operationType` | The operation class name | | `executionCompleted` | Whether `execute` finished | | `compensation` | `CommandOperationCompensation`: `NotNeeded`, `Completed`, `Failed`, `NotAvailable`, `BudgetExpired`, or `Suppressed` | | `compensationFailure` | A description when compensation failed | `recovery` and `operationOutcomes` are available on direct-call results only; they are never serialized to HTTP JSON. `CommandOperationFailure.source` is a `CommandOperationFailureSource` (`ResponseHandling`, `Execution`, `Cancellation`, or `ScopeCompletion`). It describes where the original failure occurred; the failure snapshot is passed to compensators only. ## Options | Option | Default | Meaning | | --- | --- | --- | | `commandCompensationTimeoutMs` | `30000` | Shared cooperative budget for all compensation in one command | ## Scenario assertions `CommandScenario` results offer: | Assertion | Passes when | | --- | --- | | `shouldHaveExecutedOperation(Type)` | An operation of that type finished `execute` | | `shouldHaveCompensatedOperation(Type)` | An operation of that type finished `compensate` | | `shouldHaveNoOperationInvocations()` | No operation was entered, not even partially | | `shouldHaveIndeterminateRecovery()` | The recovery status is `Indeterminate`, as after an `Unknown` or `Mixed` disposition | These inspect real pipeline observations, not simulated work. See [Testing commands](/arc/backend/typescript/testing/commands/#assert-operations). # Response value handlers > Return several values from a command with tuple(), send one to the client, and process the others on the server with scoped response value handlers. A command sometimes decides more than its client needs to see: an audit entry, a notification to queue, or an event to append. Performing those effects inside `handle()` mixes the decision with the plumbing. Instead, return them next to the response and let a **response value handler** process each one on the server. ## Return a tuple ```typescript import { field } from '@cratis/fundamentals'; import { command, commandResponseValueHandler, injectable, tuple, type CommandContext, type CommandResponseValueHandler } from '@cratis/arc.core'; export class AuditEntry { constructor(readonly text: string) {} } export class AuditLog { readonly entries: string[] = []; record(correlationId: string, text: string): void { this.entries.push(`${correlationId}: ${text}`); } } @commandResponseValueHandler() @injectable(AuditLog) export class AuditEntryHandler implements CommandResponseValueHandler { constructor(private readonly log: AuditLog) {} canHandle(_context: CommandContext, value: unknown): boolean { return value instanceof AuditEntry; } handle(context: CommandContext, value: unknown): void { this.log.record(context.correlationId, (value as AuditEntry).text); } } @command() export class CloseTask { @field(String) id!: string; handle() { return tuple({ id: this.id }, new AuditEntry(`Closed ${this.id}`)); } } ``` Register `AuditLog` with `builder.services.addSingleton(AuditLog)` and add or discover the command and the handler. `POST /api/close-task` with `{ "id": "a1" }` answers 200 with `"response":{"id":"a1"}`; the client never sees the audit entry, and `AuditLog` has recorded it. ## How Arc sorts the values 1. Arc flattens branded nested tuples and `response(...)` branches, in order. 2. For each value, it asks every registered handler’s `canHandle(context, value)`. Every matching handler runs, in deterministic name order. 3. A value no handler accepts is a candidate client response. **At most one** such value is allowed; two unhandled values fail the command instead of returning an array. 4. Ordinary arrays are not flattened: an array is one value. `canHandle` may return a boolean or a promise of one. A handler’s `handle(context, value)` may return `rejected(...)` or `denied(...)` to fail the command, but it must not return a client response. It runs in the command’s service scope and reads the [`CommandContext`](/arc/backend/typescript/commands/command-context/): the command, key, values, correlation ID, principal, tenant, allowed severity, and signal. Set `incompatibleWithOperations: true` on a handler whose effects cannot be combined with [command operations](/arc/backend/typescript/commands/operations/); Arc then rejects the combination before any effect. Cancellation during handler resolution, classification, or handling waits for the active callback to settle, then stops before starting another handler or operation. Completed handler effects are not rolled back; command scopes still complete, owned services are disposed, and a prepared operation journal remains available for recovery. ## Register a handler * Mark the class `@commandResponseValueHandler()` and add it with `builder.add(...)` or `builder.discover(...)`. It is registered as scoped. * Or register its token in `builder.services` and call `builder.addCommandResponseValueHandler(token)`. * For a low-level `ArcServer`, list tokens in the `commandResponseValueHandlers` option. The experimental [Chronicle integration](/arc/backend/typescript/chronicle/commands/) uses this mechanism to append returned events. ## Related * [Command outcomes](/arc/backend/typescript/commands/command-outcomes/) * [Command operations](/arc/backend/typescript/commands/operations/) # Validation severity filtering > Choose which validation severities block a command, per request, and why HTTP callers cannot let errors through. Not every rule should stop a command. A warning such as “this title is unusually long” can inform the user without blocking them, while a form’s final submit may want warnings to block. Arc lets the caller choose, within limits. ## Severities Each validation result has a severity: `Unknown` (0), `Information` (1), `Warning` (2), or `Error` (3). Set it on a rule with `.withSeverity(Severity.Warning)`. Results at or below the **allowed severity** are removed and do not block. Results above it block the command and are returned. The default allowed severity is `Warning`, so only errors block, and warnings and information are not returned. ## Who chooses | Caller | Allowed severity | | --- | --- | | HTTP command | `X-Allowed-Severity` header of `0`, `1`, or `2`. A missing or invalid value means `2`. A value of `3` is capped to `2`, so errors always block. | | HTTP query | Always `Warning`; the header is ignored | | `executeCommand` from code | `context.allowedSeverity` as given, including `Severity.Error` | | `performQuery` from code | Always `Warning` | A client that sends `X-Allowed-Severity: 1` makes warnings block, and sees them in the result. Results passed to `rejected(...)` in `provide()` or `handle()` are filtered the same way; when nothing above the allowed severity remains, the command continues. A deliberate difference from Arc on .NET Arc on .NET 22.45.0 accepts `X-Allowed-Severity: 3` and runs a command whose only problems are errors, unless the command declares `[BlockOnValidationSeverity]`. Arc for TypeScript caps HTTP requests at Warning, so a remote caller can never bypass business-rule errors. Only trusted code calling `executeCommand` can pass `Severity.Error`. ## Consequences * Severity never affects authorization. Put tenant and access checks in authorization, not in validators, because a trusted caller can lower the blocking severity. * The paired conformance suite pins this difference; see the [capability reference](/arc/backend/typescript/reference/capabilities/#deliberate-differences). ## Related * [Command validation](/arc/backend/typescript/commands/command-validation/) * [Calling commands from code](/arc/backend/typescript/commands/calling-commands-from-code/) # Concepts > Wrap domain values in ConceptAs types, declare them and other wire types on model-bound fields, and validate a concept wherever it appears. A task ID should not look like every other string to a handler, and a title rule should not be copied into every command that takes a title. A **concept** is a small class that wraps one primitive domain value. Arc decodes it from the wire, hands your code the concept, encodes it back to the primitive, and runs its validator wherever it appears. ## Declare a concept The [Tasks sample identifier](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/TaskId.ts) wraps a Fundamentals `Guid`: ```typescript import { ConceptAs, Guid } from '@cratis/fundamentals'; export class TaskId extends ConceptAs { static readonly valueType = Guid; static create(): TaskId { return new TaskId(Guid.create()); } } ``` Use it on a field: `@field(TaskId) id!: TaskId`. The static `valueType` is required because TypeScript erases generic arguments at runtime. Incoming UUID strings become `TaskId` instances holding a `Guid`; responses convert them back to strings. The sample’s [`TaskTitle`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/TaskTitle.ts) wraps a `String` the same way. An invalid UUID fails binding with a 400 `malformedRequest` result. Empty strings and falsy numbers and booleans are preserved, not treated as absent. ## Wire types | Declaration | Wire value | | --- | --- | | `@field(String)`, `@field(Number)`, `@field(Boolean)` | JSON string, number, boolean | | `@field(Date)` | ISO timestamp string | | `@field(Guid)` | UUID string | | `@field(DateOnly)`, `@field(TimeOnly)`, `@field(TimeSpan)` | Fundamentals date, time, and duration strings | | `@field(SomeConcept)` | The concept’s underlying primitive | | `@field(NestedModel)` | A JSON object built from the nested class’s own `@field` declarations | | `@field(Array, { genericArguments: [TaskItem] })` | A JSON array of that element type; the older `@field(TaskItem, true)` form also works | `Guid`, `DateOnly`, `TimeOnly`, and `TimeSpan` come from `@cratis/fundamentals`. Arc uses its own field metadata to decode these types, not Fundamentals’ `JsonSerializer`, and one schema drives decoding, [introspection](/arc/backend/typescript/introspection/), and [OpenAPI](/arc/backend/typescript/open-api/). The finer wire rules, including polymorphic fields and naming, are in the [wire format reference](/arc/backend/typescript/reference/wire-format/). ## Required, optional, and enumerations Every field is required unless you mark it: | Decorator | Meaning | | --- | --- | | `@optional()` | The field may be omitted | | `@nullable()` | The field may be `null` | | `@defaultValue(value)` | Omitted input takes this value | | `@enumeration(Priority)` | The scalar value must be a member of this enum object | A TypeScript enum is not a constructor that `@field` accepts. Pair `@enumeration(Priority)` with `@field(String)` or `@field(Number)`. Arc ignores the reverse-mapping names of numeric enums and rejects enum values whose type does not match the declared scalar field. ## Validate a concept everywhere A rule that belongs to the value goes on the concept, once. The [sample title validator](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/TaskTitleValidator.ts): ```typescript import { ConceptValidator, validator } from '@cratis/arc.core'; import { TaskTitle } from './TaskTitle.js'; @validator(TaskTitle) export class TaskTitleValidator extends ConceptValidator { constructor() { super(); this.ruleFor(title => title.value).must(value => !value.startsWith('!')) .withMessage('A title cannot begin with an exclamation mark'); } } ``` Arc traverses the declared `@field` members of commands, query arguments, and nested models, and runs the concept validator on every `TaskTitle` it meets, including inside arrays. A failure inside `entries[]` reports `entries.title`, not an array index. The owning model’s own rules still run independently. To skip only the *direct* member’s concept validator, call `.ignoreConceptRules()` on the owner’s `ruleFor(...)` chain; descendants are still traversed. `ModelValidator` validates a nested model class the same way wherever it appears. ## Related * [Command validation](/arc/backend/typescript/commands/command-validation/) * [Wire format reference](/arc/backend/typescript/reference/wire-format/) * [Troubleshooting](/arc/backend/typescript/troubleshooting/) for decorator and module-resolution settings # Configuration > Configure Arc through its grouped ArcOptions tree, appsettings.json, environment variables, or code, add integrations through the builder, and see what each host reads. The same application runs on your laptop, in CI, and in production. The route prefix stays put, but the tenant source, the exception detail, and the listener address change between them. You want those differences in configuration, not in `if` statements around your startup code. Arc accepts settings through one `ArcOptions` object. The serializable settings described below can come from `appsettings.json`, environment variables, and code; options marked code-only cannot be bound from configuration, and code always has the last word. The groups follow the same `Cratis:Arc` paths as [Arc on .NET](/arc/backend/csharp/configuration/): `CorrelationId`, `Tenancy`, `GeneratedApis`, `Query`, `Hosting`, `Introspection`, and `ExposeExceptionDetails`, so one `appsettings.json` shape serves both. Node-specific transport limits and registration hooks live in those groups or alongside them, as noted below. ## What each entry point reads Where options come from depends on how you create the application. The host you mount it in does not matter: Express, Fastify, and Hono take an application that is already built. See the [hosting overview](/arc/backend/typescript/overview/) for choosing a host. | Entry point | Reads `appsettings.json` and environment | Typical use | | --- | --- | --- | | `ArcApplication.createBuilder()` from `@cratis/arc.core` | Yes, unless you pass `configuration: false` | Node applications, with discovery and the standalone host | | `CratisApplication.createBuilder()` from `@cratis/cratis` | Yes, including `Cratis:Chronicle` | Arc and the experimental Chronicle integration in one call; see [The Cratis package](/arc/backend/typescript/chronicle/cratis-package/) | | `ArcApplication.createBuilder()` from `@cratis/arc.core/fetch` | No, code options only | Fetch API runtimes without a filesystem; see [Fetch API runtimes](/arc/backend/typescript/hosts/fetch-runtimes/) | | `new ArcServer(options)` | No, code options only | [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/) and specs | ## Three ways to configure `ArcApplication.createBuilder()` loads `appsettings.json` from the process working directory, then `appsettings.{Environment}.json` beside it, then `Cratis__...` environment variables, and finally applies code options. The environment comes from `DOTNET_ENVIRONMENT`, `ASPNETCORE_ENVIRONMENT`, or `NODE_ENV`, in that order. Keys are case-insensitive. Code overrides individual nested values rather than replacing the whole group. appsettings.json ```json { "Cratis": { "Arc": { "CorrelationId": { "HttpHeader": "X-Request-ID" }, "Tenancy": { "ResolverType": "Fixed", "FixedTenantId": "default" }, "GeneratedApis": { "RoutePrefix": "api", "EnableQueryHttpMethod": true }, "Query": { "KeepAliveInterval": "00:00:30" }, "Hosting": { "ApplicationUrl": "http://127.0.0.1:3000/" }, "ExposeExceptionDetails": false } } } ``` For example, `Cratis__Arc__CorrelationId__HttpHeader=X-Other-ID` overrides the file, while `correlationId: { httpHeader: 'X-Code-ID' }` overrides that environment variable: ```typescript import { ArcApplication } from '@cratis/arc.core'; const builder = ArcApplication.createBuilder({ correlationId: { httpHeader: 'X-Code-ID' } }); const app = await builder.build(); ``` Use `{ configuration: false }` to disable file and environment binding, or `{ configuration: { file: new URL('./appsettings.json', import.meta.url), env: suppliedEnvironment } }` to choose both explicitly. A string path also works. Invalid JSON and invalid known values fail setup; unknown keys within `Cratis:Arc`, `Cratis:Chronicle`, and `Cratis:MongoDB` are reported to `logger` when configured, without including their values. Other configuration sections are ignored. Do not put real connection strings in committed files. Chronicle binds `Cratis:Chronicle:{ConnectionString,EventStore}`, and MongoDB binds `Cratis:MongoDB:{Server,Database}`; clients, handlers, tokens, and other non-serializable values belong in code. `new ArcServer(options)` does not bind files or `Cratis__...` environment overrides. Its discovery and exception-detail defaults still consult the host environment variables when `process` is available. On a Fetch-only runtime without `process`, discovery defaults to requiring authentication and exception exposure defaults to false. ## Add features through the builder Storage and event sourcing are optional packages. Importing one adds its method to the builder, and the method registers everything the integration needs: | Package | Builder method | Configuration it binds | | --- | --- | --- | | `@cratis/arc.mongodb` | `builder.withMongoDB({ ... })`; see [MongoDB](/arc/backend/typescript/mongodb/getting-started/) | `Cratis:MongoDB:{Server,Database}` | | `@cratis/arc.drizzle` | `builder.withDrizzle({ ... })`; see [SQL with Drizzle](/arc/backend/typescript/sql/getting-started/) | None; pass the database in code | | `@cratis/arc.chronicle`, experimental | `builder.withChronicle({ ... })`; see [Chronicle](/arc/backend/typescript/chronicle/) | `Cratis:Chronicle:{ConnectionString,EventStore}` | Each package also exports a function form, such as `withMongoDB(builder, options)`, which does the same. Calling a method whose package you did not import fails where you call it, so a missing integration never degrades into a silent no-op. Configuration covers only serializable values. Clients, connection pools, and model classes are passed in code. ## The ArcOptions tree The paths below are relative to `Cratis:Arc` in configuration and camelCase in TypeScript. `.NET` settings with different value representations are called out explicitly. Unspecified options use the documented defaults. | Configuration path / TypeScript path | Default | Effect | | --- | --- | --- | | `ExposeExceptionDetails` / `exposeExceptionDetails` | `true` only when the effective environment is Development | Include original exception messages and stack traces in serialized HTTP results; otherwise redact them. This does not enable development discovery. | | `Development` / `development` | `false` | Enable the development user and tenant discovery providers. TypeScript-only; it does not change exception exposure. | | `Introspection:Enabled` / `introspection.enabled` | `true` | Boolean. `false` unmaps command/query catalogs and HTTP OpenAPI in every environment, not identity discovery or in-process metadata. See [Turn discovery off](/arc/backend/typescript/introspection/#turn-discovery-off). | | `CorrelationId:HttpHeader` / `correlationId.httpHeader` | `X-Correlation-ID` | Correlation ID request and response header. | | `Tenancy:ResolverType` / `tenancy.resolverType` | `header` when `tenancy` is present | Single `header`, `query`, `claim`, `subdomain`, `development`, or `fixed` source. | | `Tenancy:HttpHeader` / `tenancy.httpHeader` | `x-cratis-tenant-id` | Header source; also the fallback for `resolverType: TenantResolverType.Subdomain` or an ordered `[TenantResolverType.Subdomain, TenantResolverType.Header]` list. | | `Tenancy:BaseDomain` / `tenancy.baseDomain` | None | Required for the verified subdomain source; exactly one preceding DNS label matches. | | `Tenancy:QueryParameter` / `tenancy.queryParameter` | `tenantId` | Query-string source. | | `Tenancy:ClaimType` / `tenancy.claimType` | `tenant_id` | Claim source; only own string claims on authenticated principals count. | | `Tenancy:FixedTenantId` / `tenancy.fixedTenantId` | `development` | Fixed or development source. The .NET `DevelopmentTenantId` configuration name also binds this value; do not supply both names. | | `Tenancy:Required` / `tenancy.required` | `false` | Answer 400 when no tenant is selected. TypeScript-only. | | `Tenancy:MembershipClaim` / `tenancy.membershipClaim` | None | Require the selected tenant in this comma-separated own claim of an authenticated principal, or answer 403. TypeScript-only. | | `GeneratedApis:RoutePrefix` / `generatedApis.routePrefix` | `api` | Prefix for convention routes. | | `GeneratedApis:SegmentsToSkipForRoute` / `generatedApis.segmentsToSkipForRoute` | `0` | Leading namespace segments removed from generated routes. | | `GeneratedApis:IncludeCommandNameInRoute` / `generatedApis.includeCommandNameInRoute` | `true` | Append command names to convention routes. | | `GeneratedApis:IncludeQueryNameInRoute` / `generatedApis.includeQueryNameInRoute` | `true` | Append query names to convention routes. | | `GeneratedApis:EnableQueryHttpMethod` / `generatedApis.enableQueryHttpMethod` | `true` | Accept HTTP `QUERY` with JSON arguments as well as GET; false answers 405 with `Allow: GET`. | | `GeneratedApis:OpenApiVersion` / `generatedApis.openApiVersion` | `0.1.0` | TypeScript-only OpenAPI `info.version`; .NET does not have this ArcOptions key. | | `Query:KeepAliveInterval` / `query.keepAliveIntervalMs` | `00:00:30` / `30000` ms | Idle hub Ping interval. Files and environment use .NET’s `hh:mm:ss` format, converted to milliseconds. In code zero disables keep-alive. | | `Hosting:ApplicationUrl` / `hosting.applicationUrl` | `http://127.0.0.1:3000/` | Standalone Node HTTP listener URL; an explicit `app.start({ host, port })` or `app.run({ host, port })` overrides its host or port. HTTP adapters and Fetch-only dispatch do not open this listener. | | `Hosting:MaxBodyBytes` / `hosting.maxBodyBytes` | `1048576` | Maximum JSON command or `QUERY` body in bytes; TypeScript-only hosting limit. | With no `tenancy` group at all, Arc retains its original behavior: it reads the default tenant header unchanged and does not check membership. When you supply the group, its built-in source validates and normalizes the tenant ID. `tenancy.resolve(request, principal)` is a code-only authoritative resolver; returning `undefined` does not fall back. `tenancy.sources` is a TypeScript-only ordered list of the resolver types above; the first nonempty result wins. Do not combine `sources` and `resolverType`. `tenancy.required` answers 400 when no tenant is selected, and `tenancy.membershipClaim` requires a matching own claim on an authenticated principal or answers 403. See [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/). `development: true` enables **only** development user and tenant discovery providers. It does not enable exception details. Conversely, `exposeExceptionDetails: true` does not authorize development providers. On Node, the exception-detail default uses `DOTNET_ENVIRONMENT`, then `ASPNETCORE_ENVIRONMENT`, then `NODE_ENV`; only Development (case-insensitive) exposes details by default. The Node builder uses `configuration.env` when supplied. The code-only `environmentName` option overrides discovery’s environment, not this exception-detail default or environment-file selection; it has no `Cratis:Arc` configuration key. Set `exposeExceptionDetails` explicitly in code or configuration if needed. Keep it false on public hosts. ## Observable query limits All of these TypeScript transport settings belong under `query`; positive numeric values in `Cratis:Arc:Query` accept decimal-integer strings from the environment. The `Query:KeepAliveInterval` key is the shared .NET setting; the remaining limits, guards, Origin policy, and query health are TypeScript extensions. | TypeScript option | Default | Effect | | --- | --- | --- | | `query.allowedOrigins` | Same origin | Exact trusted HTTP(S) Origins or a code-only `(origin, request, native)` predicate; see [WebSockets](/arc/backend/typescript/hosts/websockets/#origin-checks). | | `query.observableEmissionGuards` | `[]` | Code-only scoped emission policies; see [Emission guards](/arc/backend/typescript/queries/observable-query-emission-guards/). | | `query.enableObservableHealth` | `false` | Authenticated caller-scoped health query; see [Query health](/arc/backend/typescript/queries/query-health/). | | `query.maxObservableSubscriptions` / `query.maxObservableSubscriptionsPerCaller` | `4096` / `4096` | Global and per-caller live and opening subscriptions. | | `query.maxObservableHubConnections` / `query.maxObservableHubConnectionsPerCaller` | `512` / `512` | Global and per-caller hub connections. | | `query.maxObservableHubSubscriptionsPerConnection` | `256` | Subscriptions on one hub. | | `query.maxObservableInboundFrames` / `query.maxObservableOutboundFrames` | `256` / `256` | Queued transport frames. | | `query.maxObservablePendingEmissions` | `256` | Pending snapshots from a structural subscribable. | | `query.maxObservableInboundFrameBytes` / `query.maxObservableOutboundFrameBytes` | `65536` / `1048576` | Incoming WebSocket frame or SSE control body / outgoing frame sizes. | | `query.maxObservableTombstones` | `1024` | Retained unsubscribe tombstones per hub connection for two minutes. | | `query.observableHandshakeTimeoutMs` | `10000` | WebSocket upgrade handshake deadline. | | `query.observableShutdownTimeoutMs` | `10000` | Hub and direct WebSocket cleanup deadline. | Per-caller defaults equal global limits: set smaller per-caller budgets for internet-facing hosts. A caller is an authenticated principal in its tenant. An anonymous caller is its peer address in its tenant, and all anonymous callers without a peer address in a tenant share one budget; behind a reverse proxy, that address is the proxy’s unless the adapter’s native callback supplies a verified client address. See [Per-caller budgets](/arc/backend/typescript/queries/observable-query-demultiplexer/#per-caller-budgets). Exhausted admission answers 503 with `Retry-After: 1`; an exhausted handshake answers HTTP 503 or WebSocket close 1013. The keep-alive interval alone accepts zero. `query.allowedOrigins` accepts a string array in code; the configuration binder does not accept Origin lists or predicates. ## Artifacts and services These TypeScript-only `ArcOptions` values are code-only; the builder can also register discovered artifacts and matching services directly. | Option | Default | Effect | | --- | --- | --- | | `commands`, `queries`, `observableQueries` | `[]` | Low-level definitions; see [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/). | | `services` | Owned empty registry | Registrations or an externally owned `ServiceRegistry`; see [Dependency injection](/arc/backend/typescript/dependency-injection/). | | `commandResponseValueHandlers`, `commandContextValuesProviders`, `commandKeyResolvers` | `[]` | Ordered scoped response handlers, context providers, and key rules. | | `readModelForCommandResolvers` | `[]` | Sources for `commandReadModel(...)` parameters. | | `commandExecutionRunner`, `commandExecutionScopes` | None / `[]` | Validated execution wrapper and per-command scopes. | | `commandCompensationTimeoutMs` | `30000` | Cooperative budget for [operation](/arc/backend/typescript/commands/operations/) compensation. | | `queryRenderers`, `readModelInterceptors` | `[]` | Ordered scoped read-side extensions. | ## Routes and requests A body larger than `hosting.maxBodyBytes`, measured by `Content-Length` or while reading, answers 400 `malformedRequest` for commands and `QUERY` alike. Unlike invalid `QUERY` JSON, the limit does not produce an exception envelope. Arc also rejects non-UTF-8 JSON, non-finite numbers, nesting beyond 32 levels, and the keys `__proto__`, `prototype`, and `constructor`. Fastify’s own `bodyLimit` applies first. Every result carries a correlation ID. Arc reuses a valid, non-zero UUID from `correlationId.httpHeader` in lowercase; otherwise it generates one. ## Security | Option | Default | Effect | | --- | --- | --- | | `authentication`, `authenticationSchemes` | `[]` / `{}` | Ordered and named handlers; see [Authentication](/arc/backend/typescript/core/authentication/). | | `authorizationPolicies` | `{}` | Named authorization rules, also registered through `addAuthorizationPolicy`. | | `nativePrincipal` | `false` | Accept a host-verified principal, never a caller-supplied header; see [Native principal](/arc/backend/typescript/hosts/native-principal/). | | `identityDetails` | None | Registers `/.cratis/me`; see [Identity](/arc/backend/typescript/identity/). | | `developmentUsers`, `developmentTenants` | None | Code-only fixture discovery providers; require `development: true`, independently of endpoint access. | | `environmentName` | Environment variables, otherwise non-Development | Code-only discovery environment override; no `Cratis:Arc:EnvironmentName` key. Does not affect exception exposure. See [Discovery access](/arc/backend/typescript/introspection/#production-access) for precedence. | | `introspection.enabled` | `true` | `Cratis:Arc:Introspection:Enabled`, or `Cratis__Arc__Introspection__Enabled=false` in the deployment. Code wins; invalid boolean values fail setup. Disabling catalogs does not bypass identity-discovery authentication checks or warnings. | | `introspection.requireAuthentication` | Unset | Anonymous only in Development. `false` opts out; `true` requires authentication everywhere and fails startup without authentication configured. | | `introspection.roles` | None | Comma-separated nonempty roles, any one of which grants access. Implies authentication; cannot be combined with `requireAuthentication: false`. | ## A note on CORS CORS is not an Arc option. Arc sends no `Access-Control-*` headers, and it answers a preflight `OPTIONS` request to a command or query route with 405 and an `Allow` header. A browser on another origin therefore cannot call Arc until something in front of it handles CORS. Choose one of these: * **Serve the frontend from the same origin.** Proxy `/api` and `/.cratis` through your dev server, as the Library sample’s Vite configuration does, or serve the built frontend with [static files](/arc/backend/typescript/core/static-files/). * **Use your web framework’s CORS middleware**, mounted before Arc: `cors` for Express, `@fastify/cors` for Fastify, or `hono/cors` for Hono. The middleware answers the preflight and adds the headers to Arc’s responses. * **Handle CORS at your ingress** in front of the standalone host, which has no middleware of its own. Arc accepts HTTP `QUERY` for queries by default. If cross-origin clients use it, add `QUERY` to the allowed methods; it is not a simple method, so the browser always sends a preflight. A client that only uses GET does not need it. WebSocket upgrades for observable queries are not covered by CORS. Arc checks their `Origin` against `query.allowedOrigins` itself; see [WebSockets](/arc/backend/typescript/hosts/websockets/#origin-checks). ## Errors and logging The code-only `logger(error, correlationId)` receives the original error regardless of `exposeExceptionDetails`. A callback failure produces a 500 with `hasExceptions: true`; when exposure is off, HTTP callers receive `['An unexpected error occurred']` and no stack trace. Direct calls are neither redacted nor logged. If the logger throws or rejects, Arc attempts it once and returns a generic redacted 500. A handler failure and a scope cleanup failure arrive together as one `AggregateError`. ## What startup rejects `build()` and the `ArcServer` constructor fail before serving when: * names, namespaces, paths, route prefixes, or namespace-skip counts are unsafe; * `hosting.maxBodyBytes` or a query limit is not a positive safe integer, except that `query.keepAliveIntervalMs` accepts zero; * `query.allowedOrigins` is not an exact HTTP(S) Origin list or predicate; * `tenancy` combines `resolverType` and `sources`, uses an invalid source/domain/tenant, or supplies an invalid claim name; * two operations have case-insensitively duplicate names or colliding routes, including reserved endpoints and `/validate` routes; * authorization combines anonymous with restricted access, names an unknown policy or scheme, or combines `nativePrincipal` with `authentication`; * an input schema has case-insensitively duplicate property names or cannot become JSON Schema. The builder also rejects misplaced decorators, duplicate validator targets, missing service registrations, dependency cycles, and captive lifetimes. A captive lifetime is a singleton that depends on a scoped service. It would keep the first request’s instance forever, which in a multi-tenant application means the first tenant’s data. Arc checks the declared graph without running any factory, so the check is safe in every environment. ## Low-level definition fields `defineCommand` and `defineQuery` share `name` (required), `namespace`, `path`, `summary`, `schema` (required), `authorization`, `authorize`, `validate`, `filters`, `handlerDependencies`, `validatorDependencies`, and `clientOutput`. A command also takes `handle` (required), `provide`, and `scopes`. A query takes `perform` (required); an observable query takes `observe` (required). See [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/). ## Upgrading from v0.21 v0.22 grouped the flat options under the .NET configuration paths and renamed the options type. Code that still uses the old names no longer compiles: | Before v0.22 | Now | | --- | --- | | `ArcServerOptions` | `ArcOptions` | | `correlationHeader` | `correlationId.httpHeader` | | `tenantHeader` | `tenancy.httpHeader` | | `resolveTenant` | `tenancy.resolve` | | `enableQueryMethod` | `generatedApis.enableQueryHttpMethod` | | `openApiVersion` | `generatedApis.openApiVersion` | | `maxBodyBytes` | `hosting.maxBodyBytes` | | `observableKeepAliveIntervalMs` | `query.keepAliveIntervalMs` | | `allowedOrigins` | `query.allowedOrigins` | | `maxObservable*`, `observableHandshakeTimeoutMs`, `observableShutdownTimeoutMs`, `enableObservableHealth`, `observableEmissionGuards` | The same names under `query` | In `appsettings.json`, use the grouped paths from [the ArcOptions tree](#the-arcoptions-tree). A flat key such as `Cratis:Arc:CorrelationHeader` does not bind; with a `logger` configured, Arc reports it as an unknown key. # Prepare and publish a TypeScript release > Prepare a minor or patch source-preview version, check it, and publish it as a GitHub pre-release. Nothing is published to npm. You prepare a source-preview version in the release pull request, and a maintainer publishes it as a GitHub pre-release after the merge. Nothing is automated: no workflow writes versions, tags, creates releases, or publishes to npm, and **npm publication is disabled**. ## Prepare a source-preview version From the repository root, use `yarn set-version ` instead of editing manifests or prose by hand: ```bash yarn set-version 0.20.0 yarn set-version --check git diff --check ``` Replace the example with the intended version. The command updates `version` on the versioned workspace packages (the packages under `Source`, including `Source/Tools`, and `ContractTests/Client`); the Tasks sample and HTTP contract fixture remain unversioned. It updates non-`workspace:` internal server-package dependency, peer, and optional ranges to `^`, leaving the externally published `@cratis/arc` client packages and workspace ranges alone. It also changes the preview-version statements in the README, documentation index, and package reference, then runs `yarn install` to refresh `yarn.lock`. Review the diff before committing; an install failure leaves edited files in place and fails the command so you can fix the issue and rerun it. Only stable `MAJOR.MINOR.PATCH` values are accepted. A change of major version is refused unless you pass `--allow-major`; that flag does not grant release approval. Major releases still require verified full parity and an explicit human merge. `yarn set-version --check` reads without writing and verifies package versions, non-workspace internal ranges, and all three version statements. Pass a version to `--check` to require that specific version. The command fails if any expected statement is missing; its Node tests plant version and statement drift to check that failure path. ## Check a change locally 1. Write the pull request description for framework users. Keep only nonempty `## Added`, `## Changed`, `## Fixed`, `## Removed`, `## Security`, and `## Deprecated` sections, each with a specific bullet. Use `no-release` for documentation, CI, and other changes with no outward-facing effect. Do not submit a release preview for `no-release` work. 2. Run the release guard specifications: ```bash node --test scripts/for_release/*.test.mjs ``` 3. Run the same build and test gate as the manual workflows: ```bash corepack enable yarn install --immutable yarn ci ``` `yarn ci` also runs `yarn set-version --check` and `yarn check:consumers` after the build. It installs tarballs in disposable projects outside the workspace, checks public exports and package contents, type-checks NodeNext and Bundler consumers, and exercises native ESM HTTP and CLI usage (including core without optional RxJS). Run `yarn check:consumers --self-test` separately to prove the package-content guard rejects a planted violation. Neither check publishes a package. A passing guard specification alone does not mean the framework has passed CI. The Chronicle and Drizzle consumer files skip library checks **only** for known third-party declaration errors. [CONTRIBUTING.md](https://github.com/Cratis/Arc.TypeScript/blob/main/CONTRIBUTING.md#upstream-declaration-errors) lists them. ## Preview a candidate on GitHub Run **Manual CI** on the intended commit. Save the verified user-facing notes from the merged pull request in a UTF-8 Markdown file, for example `.ai-work/keep/release-notes.md` (ignored). The file requires supported sections such as this illustrative format (not actual release notes): ```markdown ## Added - Describe a specific public capability that shipped ## Fixed - Describe a specific user-visible bug that was fixed ``` Replace both illustrative bullets with changes that actually shipped. Dispatch **Release preview (no publication)** from the current default branch; replace `main` below if that branch differs: ```bash gh workflow run release-preview.yml --ref main \ -F intent=patch \ -F release_notes=@.ai-work/keep/release-notes.md ``` The `-F` flag reads the file contents, including newlines, as a single workflow input; `-f` would pass the `@` text literally. This command **starts a hosted workflow**: run it only when you intend to spend a CI run. An optional `-F expected_current_version=1.2.3` (without the `v`) makes the preview fail if the latest stable tag disagrees. The workflow runs the release guard specifications and `yarn ci` before printing its plan. It checks that the dispatch commit is still the default-branch head, reads existing `vMAJOR.MINOR.PATCH` tags, and computes the next tag. With no tags, it starts from `0.0.0`. Other tag shapes, placeholder or empty notes, a stale branch head, and any attempt to enable npm publishing fail closed. The workflow is **manual only**, not triggered by pushes or pull requests. The preview uses read-only permissions and prints a plan; it does not update package manifests, apply labels, tag, create a release, or publish to npm. The displayed version is derived from Git tags alone. The separate `yarn set-version --check` gate verifies local manifest and documentation consistency, but the preview does **not** establish that its proposed tag matches the manifest version or that any package is ready to publish. Do not describe a preview as a completed release. ## Publish the GitHub pre-release A minor or patch release is a pull request that carries its `yarn set-version` commit, a `minor` or `patch` label, and user-facing release notes as its description. After hosted CI passes and the pull request is merged with a merge commit: ```bash merge=$(gh pr view --json mergeCommit -q .mergeCommit.oid) git fetch origin git merge-base --is-ancestor "$merge" origin/main gh pr view --json body -q .body > .ai-work/keep/release-notes.md gh release create v --prerelease --target "$merge" --title v \ --notes-file .ai-work/keep/release-notes.md ``` Create the release only after `git merge-base` confirms that the merge commit is on `main`, and target that commit, not the moving branch. `gh release create` also creates the `v` tag, which the release preview workflow reads. The pre-release publishes source only; npm packages stay unpublished. Merge release pull requests in version order, because each carries its own `yarn set-version` change. ## Before enabling publication Review the package inventory and versioning scheme, including whether any packages should stay private (none of the eleven packages under `Source` is marked `private`, including the experimental `@cratis/arc.chronicle` and `@cratis/cratis`; only the samples and contract-test fixtures are), verify every published manifest against the planned version, configure npm trusted publishing and a complete post-publish verification gate, and review failure behavior before granting write permissions or adding any release effect. A release must fail rather than report success after a partial publish. Neither this page nor the preview authorizes a publication workflow. **Major releases are never automatic.** They require a human merge and may proceed only after Arc parity has been verified. A minor/patch preview is not a shortcut around those requirements. Merge pull requests with a true merge commit, not squash or rebase; merging and publishing are outside this workflow. # Change a sample > Regenerate proxies and metadata, lint, and check client generation after you change the Tasks or Library sample, and find where each Arc for .NET test-app behavior is covered in this repository. The Tasks and Library samples in this repository are checked by the same gate as the packages. Both samples commit generated metadata and co-located proxies, so a change to a decorated artifact has to be followed by a regeneration, or `yarn ci` fails. This page is for contributors to this repository; applications have their own scripts. ## After you change a slice Run these from the repository root. The `generate-proxies` scripts run the proxy generator from its `dist` folder, so run `yarn build` first. | Command | What it does | | --- | --- | | `yarn workspace @cratis/arc.core.sample.tasks generate-proxies` | Regenerates `Samples/Tasks/Features/generatedMetadata.ts`, writes proxies beside its backend slices, and type-checks the proxies | | `yarn workspace @cratis/arc.sample.library generate-proxies` | Regenerates `Samples/Library/Features/generatedMetadata.ts` and proxies beside its backend slices | | `yarn check:metadata` | Fails when either sample’s committed metadata differs from its source | | `yarn check:proxies` | Fails when either sample’s committed proxies differ from regenerated output, or new proxies are untracked; run after both `generate-proxies` commands | | `yarn lint:tasks:arc` | Runs the Arc ESLint rules over `Samples/Tasks/Features` with type information | | `yarn test:client-generation` | Builds, regenerates the Tasks proxies, compiles the client fixtures, and runs the generated proxies against Express, Fastify, and Hono | Commit the regenerated files with the source change. Do not hand-edit generated metadata or browser proxies. ## Where the .NET test-app behaviors live Arc for .NET exercises its hosts with two test applications in the Arc repository: `TestApps/ArcCore`, a standalone Arc.Core host, and `TestApps/AspNetCore`, an ASP.NET Core host with MongoDB and Swagger. Both share their features from `TestApps/Shared`. This repository has no test-app folders of the same name. Each behavior is covered by a sample, an adapter suite, or a contract check instead: | Behavior | .NET reference (Arc 22.45.0) | Arc for TypeScript | Checked by | | --- | --- | --- | --- | | Standalone host without a web framework | `TestApps/ArcCore/Program.cs` | `Samples/Tasks/main.ts` with `app.run()` | `yarn build`, `yarn check:metadata` | | Route prefix, skipped namespace segments, and command name in route | `ArcCore/Program.cs` options | `generatedApis` options | `Source/Core/for_ArcApplicationBuilder/when_building_model_bound_routes` | | Model-bound command and read model | `Shared/ModelBoundCommand.cs`, `ModelBoundReadModel.cs` | `Samples/Tasks` `RegisterTask` and `TaskItem`; `Samples/Library` slices | Slice specs under each sample’s `for_*` folders | | Command validator | `AspNetCore/DoStuffValidator.cs` | `TaskTitleValidator` in Tasks; `AddBookValidator` in Library | `when_validating/with_an_empty_title`, `when_adding/with_an_empty_title` | | Query by argument, paging, and sorting | `Shared/Features/QueryShowcase` | Tasks `taskById` and `allTasks`; Library `authorsPage` | `when_performing/with_a_concept_argument`, `with_sorting_and_paging`, Library `when_paging` | | Observable queries | `Shared/Features/Ticker`, `LiveFeed`, `ObservableCollection`, `ChangeStream` | Tasks `observeAllTasks` (RxJS `BehaviorSubject`); Library `allAuthors` from Chronicle | Tasks `when_collecting/with_current_value`; `ContractTests/Client/observable-*.test.mjs` over SSE, WebSockets, and the hub | | Authentication handlers | `Shared/Authentication` cookie and Microsoft identity platform handlers | `jwtBearer()` and `microsoftIdentityPlatform()`; a native principal in the Library host | `Source/Core/authentication/for_*`; `Source/Express/for_identityHosts` | | Anonymous and authenticated queries, roles, and cross-cutting authorization filters | `Shared/Features/AuthenticationQueries`, `CrossCuttingAuthorization` | `@roles('Librarian')` in Library; authorization filters in Arc.Core | Library `with_librarian_role`, `without_librarian_role`; `yarn test:conformance` | | Host adapters | `AspNetCore/Program.cs` (ASP.NET Core) | `@cratis/arc.express`, `@cratis/arc.fastify`, `@cratis/arc.hono`; Library runs on Express | `Source/{Express,Fastify,Hono}/for_cratisArc`; `yarn test:client-generation` runs generated proxies against all three | | MongoDB collections and the change-stream watcher | `AspNetCore/Features/MongoWatcher` | `@cratis/arc.mongodb` `observe()` and `MongoDBWatcher` | `Source/MongoDB/run-integration.sh`, including `with_each_http_adapter` | | React frontend with generated proxies | `ArcCore/main.tsx`, `AspNetCore/main.tsx`, `Shared/Features/*Page.tsx` | `Samples/Library/Features/**/*.tsx` (app shell in `Web/src`) | `yarn workspace @cratis/arc.sample.library test:e2e` exercises the proxies, not the browser UI | | API description | Swagger UI through `Cratis.Arc.Swagger` | `GET /openapi.json`, without a bundled UI | `Source/Core/openApi/for_renderOpenApi` | Three .NET behaviors have no counterpart by design: * **Controllers.** `AspNetCore/Commands.cs`, `ObservableQueries.cs`, and `LiveFeedController.cs` use ASP.NET controllers. Arc for TypeScript serves only model-bound and low-level operations; routes you add to your host sit beside Arc. * **Cookie authentication.** The shared `CookieAuthenticationHandler` is a .NET test fixture. Use your host’s session handling and a [native principal](/arc/backend/typescript/hosts/native-principal/). * **`FromRequest` binding.** It is C#-only; see [OpenAPI](/arc/backend/typescript/open-api/#topics). Add a runnable sample only for a journey no sample, adapter suite, or contract check demonstrates yet, not to match a .NET folder name. ## Before you open a pull request `yarn ci` runs all of the above with the rest of the gate. [Contributing](https://github.com/Cratis/Arc.TypeScript/blob/main/CONTRIBUTING.md) lists every step, and [Prepare and publish a TypeScript release](/arc/backend/typescript/contributing/releases/) covers release checks and publication. ## Related * [Keep a behavior together in a vertical slice](/arc/backend/typescript/vertical-slices/) * [Proxy generation](/arc/backend/typescript/proxy-generation/) # Arc.Core and the standalone Node host > Run an Arc application on Node's own HTTP server, own the listener yourself, and shut it down without losing in-flight work. Not every service needs a web framework. A worker that exposes a few commands, or a small application that serves its own frontend, would otherwise pull in Express only to listen on a port and shut down cleanly. `@cratis/arc.core` is the whole Arc application model: the command and query pipelines, validation, authorization, services, and the result envelope. It also carries a small Node host, so an application can serve its routes, and a built frontend, without Express, Fastify, or Hono. When you do use one of those frameworks, the same application mounts in it unchanged; see [Host adapters](/arc/backend/typescript/hosts/). Source preview `@cratis/arc.core` is not published to npm. Install a tarball packed from a clone of this repository, or use it inside the clone with the `workspace:^` protocol, as `Samples/Tasks/package.json` does. [Create an application](/arc/backend/typescript/getting-started/create-an-application/) shows both paths. ## Run a built application `ArcApplication` wraps the `ArcServer` that the [application builder](/arc/backend/typescript/core/getting-started/) creates, and owns a standalone listener when you ask it to: main.ts ```typescript import { ArcApplication } from '@cratis/arc.core'; import { Tasks } from './Features/Tasks/Tasks.js'; import { metadata } from './Features/generatedMetadata.js'; const builder = ArcApplication.createBuilder(); builder.useGeneratedMetadata(metadata); builder.services.addSingleton(Tasks); await builder.discover(new URL('./Features/', import.meta.url)); export const app = await builder.build(); await app.run({ port: Number(process.env.PORT ?? 3000) }); ``` This is the [Tasks sample entry point](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/main.ts). `app.run()` starts the listener and waits until SIGINT, SIGTERM, or `app.stop()`, then closes the listener and disposes the application. Its signal handlers are removed when it returns. | Method | Behavior | | --- | --- | | `app.run(options?)` | Start, then wait for a signal or `stop()`; closes gracefully | | `app.start(options?)` | Start listening and return; the caller decides when to stop | | `app.stop()` | Coordinate participant shutdown with listener closure, then dispose services | | `app.dispose()` | Same as `stop()`, whether or not a listener was started | | `app.server` | The `ArcServer`, for direct calls, adapters, and introspection | A stopped or disposed application cannot start again. ## Listener options `run()` and `start()` accept the same options: | Option | Default | Effect | | --- | --- | --- | | `port` | `3000` | TCP port; `0` picks an ephemeral port | | `host` | `'127.0.0.1'` | Interface to bind. The default is loopback, not every interface | | `https` | None | Node `https` server options, such as `{ key, cert }` | | `pathBase` | None | A path prefix removed before Arc dispatch and static-file lookup | | `staticFiles` | None | Serve a public directory; see [Static files](/arc/backend/typescript/core/static-files/) | | `fallback` | None | SPA navigation fallback file inside `staticFiles.root` | | `native` | None | A trusted callback returning a host-verified principal or authority for each request | `pathBase` is matched case-insensitively and must be a segment-delimited path with no trailing slash. Unlike .NET’s `UsePathBase`, this host also moves static files under the base; introspection route values and the OpenAPI document are not rewritten to include it. ## Host a low-level server If you build an `ArcServer` directly from low-level definitions, `runArc(server, options)` takes the same options and returns `{ server, close, shutdown }`: server.ts ```typescript import { ArcServer, defineCommand, runArc } from '@cratis/arc.core'; import { z } from 'zod'; const echo = defineCommand({ name: 'Echo', schema: z.object({ value: z.string() }), handle: ({ value }) => value }); const arc = new ArcServer({ commands: [echo] }); const host = await runArc(arc, { port: 3000, host: '127.0.0.1' }); process.once('SIGINT', () => { void host.shutdown(); }); ``` `curl -X POST http://127.0.0.1:3000/api/echo -d '{"value":"hello"}'` answers with `"response":"hello"`. `host.close()` closes only the listener; `host.shutdown()` coordinates listener closure and Arc disposal. If you supplied a caller-owned registry, pass it explicitly as `host.shutdown({ registry })`. Neither `close()` nor a plain `server.dispose()` takes ownership of a caller-owned registry. ## Shut down without dropping work Use `host.shutdown({ timeoutMs, registry? })` for coordinated shutdown. With participants, it closes Arc admission before initiating listener and transport closure; producer release and transport closure finish before participant stop. Drains finish before delivery work and scopes are disposed. Without participants, it retains the original host-first order. Repeated shutdown joins the first outcome. If you supplied a caller-owned registry, pass that **same** registry explicitly; Arc never disposes it implicitly. Without it, shutdown closes the listener and performs local server cleanup only, and the registry and its participants stay with their owner. For other hosts, import `shutdownArcHost(server, () => closeListener(), registry?)` from `@cratis/arc.core/hosting` and pass the borrowed registry only when its owner authorizes disposal. `close({ timeoutMs })` remains listener-only. It stops accepting HTTP connections, closes Arc WebSockets and ends live server-sent-event streams, then waits up to 30 seconds by default for ordinary requests and WebSockets to drain. At the deadline it closes remaining connections and rejects if shutdown is incomplete. WebSocket cleanup has its own `query.observableShutdownTimeoutMs` bound in [configuration](/arc/backend/typescript/configuration/); an earlier host deadline can reject before that cleanup finishes. A producer that ignores cancellation, or whose cleanup waits for a later participant phase, cannot complete coordinated shutdown; Arc does not dispose its live dependencies on a timeout. ## Bring your own Node server When you already own a `node:http` server, use `createArcNodeHandler` as its request handler. A request handler cannot see WebSocket upgrades, so attach those explicitly on the same listener: server.ts ```typescript import { createServer } from 'node:http'; import { createArcNodeHandler } from '@cratis/arc.core'; import { attachNodeWebSockets, shutdownArcHost } from '@cratis/arc.core/hosting'; import { app } from './app.js'; const listener = createServer(createArcNodeHandler(app.server)); const closeSockets = attachNodeWebSockets(listener, app.server); listener.listen(3000, '127.0.0.1'); process.once('SIGTERM', async () => { await shutdownArcHost(app.server, async () => { await closeSockets(); await new Promise(resolve => listener.close(() => resolve())); }); }); ``` Here `app` is a built `ArcApplication` from your own module. With a path base, pass it as the fourth `attachNodeWebSockets` argument; a trusted native resolver goes third. `createArcNodeHandler` does not manage listener errors, server-sent-event shutdown, or connection draining; you own those on your server. `runArc` and `app.run()` already own upgrades on their listener, so do not attach a second bridge there. ## Security defaults * Arc enforces `hosting.maxBodyBytes` (1 MiB by default) on raw command and `QUERY` bodies, including chunked input, and aborts `context.signal` when the client disconnects. * For a host-authenticated caller, set `nativePrincipal: true` and pass a `native(request)` callback that returns `{ principal }` after your host has verified the request. The callback applies to HTTP and WebSocket requests. Never derive the principal or `authority` from request or forwarded headers. See [Native principal](/arc/backend/typescript/hosts/native-principal/). * Cookie security is taken from the actual TLS socket, not from headers. ## Related * [Build an application](/arc/backend/typescript/core/getting-started/) * [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) * [Static files and SPA fallback](/arc/backend/typescript/core/static-files/) * [Host adapters](/arc/backend/typescript/hosts/) if your application already uses a web framework # Authentication > Decide who verifies credentials, write or configure Arc authentication handlers for JWT bearer tokens or EasyAuth headers, and know what Arc never trusts. Before an operation can say “editors only”, something has to establish who the caller is. Arc does not infer a caller from a cookie or a forwarded header. You choose who verifies credentials, and the rest of the pipeline works from the principal that choice produces. ## Choose an authentication boundary | Your host | Arc configuration | What must be trusted | | --- | --- | --- | | Express, Fastify, or Hono already authenticated the caller | `nativePrincipal: true` plus a verified adapter callback | The host’s session or JWT middleware and the callback; see [Native principal](/arc/backend/typescript/hosts/native-principal/) | | Your Node server validates JWTs | `authentication: [jwtBearer({ jwksUrl, issuer, audience, algorithms })]` | An HTTPS JWKS URL and pinned issuer, audience, and algorithms | | Azure EasyAuth or a reverse proxy forwards a principal | `authentication: [microsoftIdentityPlatform()]` | The ingress **must** strip client-supplied `x-ms-client-principal*` headers, set its own, and block direct backend access | | Your own credential check | `authentication: [yourHandler]` | Your handler | `nativePrincipal: true` and Arc authentication handlers are mutually exclusive. ## How the handler chain decides An `AuthenticationHandler` receives the Fetch API `Request` and returns one of three results: | Result | Meaning | | --- | --- | | `{ status: AuthenticationStatus.Anonymous }` | This handler does not recognize the request. The next handler runs. | | `{ status: AuthenticationStatus.Authenticated, principal }` | The caller is known. No further handler runs. | | `{ status: AuthenticationStatus.Failed }` | The request carried wrong credentials. The request ends with 401, whatever the operation requires. | Handlers run in the order you list them and can be `async`. A principal must have `isAuthenticated: true`, a string `id`, and an array of string `roles`; `name`, `claims`, and `scheme` are optional. Arc copies and freezes the roles and the own claim dictionary, and freezes the principal it exposes as `context.principal`. When no handler is configured, nobody is authenticated, and a protected operation answers 403 rather than 401. ## Write a handler This handler is for local development only: it accepts one fixed token instead of verifying anything. developmentUser.ts ```typescript import { AuthenticationStatus, type AuthenticationHandler } from '@cratis/arc.core'; // Development only: a fixed token instead of real token verification. export const developmentUser: AuthenticationHandler = request => request.headers.get('authorization') === 'Bearer ada-dev-token' ? { status: AuthenticationStatus.Authenticated, principal: { id: 'ada', roles: ['editor'], isAuthenticated: true } } : { status: AuthenticationStatus.Anonymous }; ``` Pass it with `ArcApplication.createBuilder({ authentication: [developmentUser] })`. ## Verify JWT bearer tokens main.ts ```typescript import { ArcApplication, jwtBearer } from '@cratis/arc.core'; const builder = ArcApplication.createBuilder({ authentication: [jwtBearer({ jwksUrl: new URL('https://login.example.com/.well-known/jwks.json'), issuer: 'https://login.example.com/', audience: 'tasks-api', algorithms: ['RS256'] })] }); ``` `jwtBearer()` ignores requests without a Bearer authorization header, rejects invalid Bearer tokens, and verifies the signature, issuer, audience, expiration, and subject; issued-at is optional. It uses `jose` with a remote JWKS. `clockSkewSeconds` (default 0), `jwksTimeoutMs`, and `jwksCooldownMs` bound time drift and key retrieval. It requires an HTTPS JWKS URL, a nonempty audience, and an explicit asymmetric algorithm list. The principal’s `id` is the token’s `sub`, and its roles come from the `roles` or `role` claim. Keep tokens out of URLs. `jwtBearer()` is specific to Arc for TypeScript; .NET hosts use ASP.NET Core authentication instead. ## Accept EasyAuth headers behind a trusted ingress `microsoftIdentityPlatform()` reads the `x-ms-client-principal`, `x-ms-client-principal-id`, and `x-ms-client-principal-name` headers. It is opt-in. Partial headers are anonymous; a malformed encoded principal or an empty principal ID fails authentication. It builds the principal the way Arc on .NET does: `id` from `x-ms-client-principal-id`, `name` from the encoded `userDetails` (not the name header). It removes inbound `sub`, .NET `NameIdentifier`, and `urn:cratis:arc:identity:provider` claims before writing them from the forwarded fields, copies other claims, and uses `userRoles` and existing role claims for roles. The forwarded provider field is metadata, not a durable issuer key. An EasyAuth header is base64, not a signature Anyone who can reach the backend directly can send these headers. Only enable `microsoftIdentityPlatform()` behind an ingress that strips caller-supplied identity headers and blocks direct traffic. Never enable it together with another handler on an untrusted route without deciding which one recognizes a credential first. ## Named schemes Register `authenticationSchemes: { Verified: handler }` to keep a handler out of the default chain and select it per operation with `@authorize({ schemes: ['Verified'] })`. See [Authorization policies and schemes](/arc/backend/typescript/core/authorization/#select-an-authentication-scheme). ## What Arc never trusts * The `.cratis-identity` cookie is display data for the frontend. Neither handler, nor anything else in Arc, turns it into a credential. See [Identity](/arc/backend/typescript/identity/). * `X-Forwarded-*` headers never become a principal, a TLS flag, or an authority by themselves. * By default, the Express, Fastify, and Hono adapters do not pass on a user that the framework’s own middleware authenticated. Bridge it explicitly with a [native principal](/arc/backend/typescript/hosts/native-principal/). ## Related * [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/) * [Authorization policies and schemes](/arc/backend/typescript/core/authorization/) * [Entra API bearer recipe](/arc/backend/typescript/identity/entra-bearer/) * [AuthProxy and EasyAuth recipe](/arc/backend/typescript/identity/authproxy-easyauth/) * [Observable transport authentication](/arc/backend/typescript/identity/observable-authentication/) * [WebSockets](/arc/backend/typescript/hosts/websockets/) for authenticating upgrades # Authorization policies and schemes > Register named authorization policies, read claims, make ownership decisions from your own data, understand authorization results, test them with CommandScenario, and select a named authentication scheme per operation. Roles answer “is this caller an editor?”. Some rules need more: the caller’s department, a subscription level, or whether they own the document they are about to archive. Scattering those checks through handlers makes them easy to forget on the next command. Arc gives each kind of rule one place: a named policy for rules about the caller, and `provide()` for rules about the data. Neither one authenticates the caller; that already happened in an [authentication handler](/arc/backend/typescript/core/authentication/). ## Register and use a policy ```typescript import { ArcApplication, authorize, command } from '@cratis/arc.core'; @authorize({ policy: 'Finance', roles: ['Reader', 'Admin'] }) @command() class ReadLedger { handle(): string { return 'authorized'; } } const builder = ArcApplication.createBuilder({ /* configure verified authentication */ }); builder.add(ReadLedger); builder.addAuthorizationPolicy('Finance', async (principal) => (principal.claims as Record | undefined)?.department === 'finance'); const app = await builder.build(); ``` The snippet shows authorization only; configure [authentication](/arc/backend/typescript/core/authentication/) for real use. `@authorize('Finance')` is shorthand for the policy without roles, and `@authorize()` alone requires authentication. A function policy receives the selected principal and the execution context. Return `true` to allow and `false` to deny. Every command and query that names `Finance` now runs the same check, before validation and before your code. ## Write a policy class When a policy needs services, pass a class implementing `AuthorizationPolicy` to `addAuthorizationPolicy(name, PolicyClass)`. The builder registers it as scoped, resolves its constructor dependencies from the execution scope, and calls `authorize({ principal, target, resource })`. `target` is the command or query definition; `resource` holds the unvalidated `input` and the `execution` context. A thrown error fails the operation with a 500 and is redacted on production HTTP routes, so a broken policy never lets a caller through. Unknown policies and unknown named schemes throw `InvalidAuthorizationConfiguration` **at build**, and a duplicate policy name is rejected. For a low-level `defineCommand` or `defineQuery`, write `authorization: { policy: 'Finance', authenticated: true }` and register the policy in the `authorizationPolicies` option. ## How declarations combine One declaration’s roles are alternatives (OR). Stacked `@authorize` or `@roles` decorators on the same class or method are separate requirements that must all pass (AND). An explicit declaration on a `@query()` method replaces, rather than combines with, the read-model class declaration; otherwise the method inherits the class’s. Command authorization is class-only: decorating `handle()`, `provide()`, or another command method fails at build. `@allowAnonymous()` cannot share a declaration with an authenticated requirement. [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/) covers the decorators and the order of checks. ## Work with claims Arc’s `Principal` has `id`, `roles`, `isAuthenticated`, and optional `name`, `scheme`, and `claims`. `claims` is typed `unknown`, because its shape comes from whoever authenticated the caller: | Authenticated by | `principal.claims` | | --- | --- | | [`jwtBearer()`](/arc/backend/typescript/core/authentication/#verify-jwt-bearer-tokens) | The verified token payload, such as `{ sub, department, roles }` | | [`microsoftIdentityPlatform()`](/arc/backend/typescript/core/authentication/#accept-easyauth-headers-behind-a-trusted-ingress) | A dictionary from claim type to value, including `sub` and the .NET name and name-identifier claim types | | A [native principal](/arc/backend/typescript/hosts/native-principal/) or your own handler | Whatever your code put there | Narrow the value before you use it, as the `Finance` policy above does. Arc freezes the claim dictionary, so no step of the pipeline can change it. You can read the principal wherever you need it: * In a policy, from the `principal` argument. * In a command’s `provide()` or `handle()`, by injecting `commandContext()` and reading `context.principal`. * In any service that runs during a command or query, with `currentContext()?.principal`. It uses Node’s `AsyncLocalStorage`, so concurrent requests never see each other’s principal, and it returns `undefined` outside an Arc execution. [Identity details](/arc/backend/typescript/identity/) are display data for the frontend. They never add claims to this principal. ## Decide from your own data “Only the owner may archive a document” is not a rule about the caller alone. You have to load the document first. Do that in `provide()` and return `denied(reason)` when the caller may not continue: Features/Documents/ArchiveDocument.ts ```typescript import { field } from '@cratis/fundamentals'; import { command, commandContext, denied, inject, rejected, validation, type CommandContext } from '@cratis/arc.core'; import { Documents, type StoredDocument } from './Documents.js'; @command() export class ArchiveDocument { @field(String) id!: string; @inject(Documents, commandContext()) provide(documents: Documents, context: CommandContext) { const document = documents.byId(this.id); if (!document) return rejected(validation('The document does not exist', ['id'], 'notFound')); return document.owner === context.principal?.id ? document : denied('Only the owner can archive a document'); } handle(document: StoredDocument): void { document.archived = true; } } ``` `Documents` is your own service. `provide()` runs after authorization and validation, so the caller is already authenticated and the input is well-formed. `denied(...)` stops the command before `handle()` runs and answers 403 with the reason. A missing document is a different outcome: `rejected(...)` answers 400 with a validation result. See [Command outcomes](/arc/backend/typescript/commands/command-outcomes/). For a low-level definition, the per-request `authorize(input, context)` callback makes the same decision before validation; see [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/#decide-per-request). Keep permission checks out of validators A validator result is a validation error, not a denial, and a trusted direct caller can lower the blocking severity. Put ownership and tenant checks in a policy, `provide()`, or `authorize`, where nothing a caller sends changes the outcome. ## Authorization results When authorization fails, the command or query result has `isAuthorized: false`, and your code never ran: | Situation | HTTP status | Result | | --- | --- | --- | | Anonymous caller on a protected operation, with at least one authentication handler configured | 401 | `isAuthorized: false` | | The authentication handler returned `Failed` | 401 | `isAuthorized: false` | | Anonymous caller on a protected operation, with no authentication handler | 403 | `isAuthorized: false` | | Authenticated caller without a required role, or a policy returned `false` | 403 | `isAuthorized: false` | | `provide()` or `handle()` returned `denied(reason)` | 403 | `isAuthorized: false`, `authorizationFailureReason: reason` | | A policy threw | 500 | `hasExceptions: true`, redacted outside development | Arc on .NET answers 403 for an anonymous caller in the first row; this is a [deliberate difference](/arc/backend/typescript/reference/capabilities/#deliberate-differences). A generated frontend proxy reads the same `isAuthorized` flag from the result, so the UI can tell “not allowed” apart from “invalid input”. ## Test authorization `CommandScenario` runs a command through the real pipeline, including authorization. Set the principal with `withContext` and assert the result: Features/Documents/for_ArchiveDocument/when_archiving/as_someone_else.ts ```typescript import { CommandScenario, type ScenarioCommandResult } from '@cratis/arc.testing'; import { ArchiveDocument } from '../../ArchiveDocument.js'; import { Documents } from '../../Documents.js'; describe('when archiving a document as someone else', () => { const scenario = CommandScenario.for(ArchiveDocument); scenario.services.addSingleton(Documents, new Documents()); scenario.withContext({ principal: { id: 'bob', roles: [], isAuthenticated: true } }); let result: ScenarioCommandResult; beforeAll(async () => { result = await scenario.execute({ id: 'doc-1' }); }); afterAll(async () => { await scenario.dispose(); }); it('should not be authorized', () => { result.shouldNotBeAuthorized(); }); it('should give the reason', () => { result.authorizationFailureReason.should.equal('Only the owner can archive a document'); }); }); ``` This assumes a `Documents` service in which `doc-1` belongs to `ada`. Cover at least three principals for each protected command: anonymous, authenticated without the role or ownership, and allowed. The [Library sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Library/Features/Authors/Registration/for_RegisterAuthor/when_registering) does this for `@roles('Librarian')` with and without the role. A scenario calls the pipeline directly, so it never runs your authentication handlers. Test those, and any ingress that forwards identity, over HTTP. See [Testing commands](/arc/backend/typescript/testing/commands/) for the full scenario API. ## Select an authentication scheme Register named handlers with `authenticationSchemes: { Verified: handler }` and require one with `@authorize({ schemes: ['Verified'] })`. For that operation, Arc tries only the selected handlers in declaration order, strips any scheme the handler set, marks the verified principal with the selected `scheme`, and authorizes against it. The first recognized result wins; several named handlers do not merge identities. * With `nativePrincipal: true`, the trusted host callback must supply a principal carrying the selected `scheme`. A scheme with no registered handler fails at build. * Direct calls also need a trusted principal that carries the selected scheme. * Stacking two declarations that both name schemes is rejected at build: one principal cannot satisfy two independent scheme requirements. * Observable queries cannot require a scheme, because the multiplexed hub does not authenticate per subscription. Use a policy or roles for them. Do not confuse a named policy with an authentication scheme: a policy decides, a scheme authenticates. ## Compared with Arc on .NET Arc on .NET 22.45.0 evaluates named policies through scoped `IAuthorizationPolicy` implementations; an unknown name throws `InvalidAuthorizationConfiguration`. Its policy context holds a principal, a reflected command type or query method (`Target`), and a command or query context (`Resource`). The TypeScript class form receives an operation definition and `{ input, execution }` instead, and a function form is also available. Node schemes select Arc handlers, not ASP.NET Core authentication with its challenge and forbid composition. ## Related * [Authentication](/arc/backend/typescript/core/authentication/) * [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/) * [Tenancy](/arc/backend/typescript/tenancy/), for tenant membership * [Capability reference](/arc/backend/typescript/reference/capabilities/#security-identity-tenancy-and-correlation) # Endpoint mapping > How Arc derives command and query routes from namespaces and names, how to change the convention, and how to pin a route with @path. Arc derives every route. You never register a URL by hand, and a generated client always calls the same URL the server serves. This page explains the convention and the two ways to change it: configure it for the whole application, or override it for one artifact. ## The convention A route is the prefix, the namespace segments, and the name, each converted to kebab case: 1. Start with `generatedApis.routePrefix`, `api` by default. An empty string removes the prefix. A prefix may contain letters, digits, `_`, `-`, and `/` between segments. 2. Split the namespace on dots and drop the first `generatedApis.segmentsToSkipForRoute` segments. 3. Convert each remaining segment and the name to kebab case. Acronyms stay together: `CreateTask` becomes `create-task`, `HTTPReader` becomes `http-reader`, and `_` becomes `-`. For a model-bound artifact, the namespace comes from its folder below the [discovery root](/arc/backend/typescript/core/getting-started/#discover-artifacts-from-a-folder), or from `@command({ namespace })` and `@readModel({ namespace })`. The name is the command class name, or the query method name: | Artifact | Namespace | Route | | --- | --- | --- | | `RegisterTask` in `Features/Tasks/Registration/` | `Tasks.Registration` | `POST /api/tasks/registration/register-task` | | `TaskItem.allTasks` in `Features/Tasks/Listing/` | `Tasks.Listing` | `GET /api/tasks/listing/all-tasks` | A query’s identity still includes the read-model class, `Tasks.Listing.TaskItem.allTasks`, even though the route does not. Direct calls and generated clients use that identity. Every command also gets a validation route: its route followed by `/validate`. A command whose own route ends in `/validate`, such as one named `Validate`, still executes on that route. ## Change the convention Pass `generatedApis` to `ArcApplication.createBuilder(...)` or `new ArcServer(...)`: ```typescript const builder = ArcApplication.createBuilder({ generatedApis: { routePrefix: 'api', segmentsToSkipForRoute: 1, includeCommandNameInRoute: true, includeQueryNameInRoute: true } }); ``` | Option | Default | Effect | | --- | --- | --- | | `routePrefix` | `'api'` | First route segment; `''` for none | | `segmentsToSkipForRoute` | `0` | Leading namespace segments to drop, such as a company name | | `includeCommandNameInRoute` | `true` | Omit the command name when `false`, unless that would make two routes in one namespace collide | | `includeQueryNameInRoute` | `true` | The same for queries | Keep the [proxy generator](/arc/backend/typescript/proxy-generation/configuration/) route options in step with these, or generated clients call the wrong URL. ## Pin one route with @path Moving a file changes its derived route. When a public URL must not move, override it: ```typescript @command() @path('/api/tasks/register') export class RegisterTask { /* fields and handle() */ } ``` `@path` works on a command or read-model class, and on a `@query()` method, where it overrides the class path. The path must start with a single `/`, contain only letters, digits, `/`, `_`, and `-`, and must not contain `..`. A trailing slash is removed, and the prefix is not added. Low-level definitions take `path` as a property instead. `@path` on a static method without `@query()` fails at build: it would have no effect. ## What startup rejects The server refuses to start rather than serve a weakened contract when a name or namespace segment does not start with a letter or contains anything but letters, digits, and `_` (even with an explicit path), when a path or prefix is unsafe, when two operations share a namespace and name (compared case-insensitively), or when two routes collide, including a command’s `/validate` route and the reserved `/.cratis` and `/openapi.json` paths. The full list is in [Configuration](/arc/backend/typescript/configuration/#what-startup-rejects). ## Reserved paths | Path | Purpose | | --- | --- | | `/.cratis/commands`, `/.cratis/queries` | [Introspection](/arc/backend/typescript/introspection/) | | `/.cratis/identity-details/schema`, `/.cratis/me` | [Identity](/arc/backend/typescript/identity/) | | `/.cratis/users`, `/.cratis/tenants` | [Development users and tenants](/arc/backend/typescript/identity/development-users-and-tenants/) | | `/.cratis/queries/ws`, `/.cratis/queries/sse` | [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/) | | `/.cratis/queries/health` | [Query health](/arc/backend/typescript/queries/query-health/), when enabled | | `/openapi.json` | [OpenAPI](/arc/backend/typescript/open-api/) | The complete route table, with methods and answers, is in the [HTTP contract reference](/arc/backend/typescript/reference/http-contract/). # Build an Arc application > Collect model-bound artifacts with the application builder, discover them from a folder or add them explicitly, and run or mount the built application. An application builder collects your commands, read models, validators, and services, checks that they fit together, and creates the `ArcServer` that every host uses. You describe the application once; the host is a separate choice. ## Create, fill, and build The [Tasks entry point](/arc/backend/typescript/getting-started/#see-what-started-the-server) shows discovery, service registration, and generated metadata in one place. For an artifact with no injected parameters, you can also add it explicitly: Status.ts ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel } from '@cratis/arc.core'; @readModel() export class Status { @field(String) value = 'ready'; @query() static current(): Status { return new Status(); } } ``` main.ts ```typescript import { ArcApplication } from '@cratis/arc.core'; import { Status } from './Status.js'; const builder = ArcApplication.createBuilder(); builder.add(Status); export const app = await builder.build(); await app.run({ port: Number(process.env.PORT ?? 3000) }); ``` The Tasks command and queries do take parameters. In the Tasks bootstrap, `useGeneratedMetadata(metadata)` installs the bindings that `arc-proxygenerator --metadata` extracted from the source, so `handle(tasks: Tasks)` and a bare `@query()` need no token lists. Call it before `discover()` or `add()`. Without it, the sample’s artifacts would need explicit `@inject(...)` and `@query(...)` tokens, and `build()` rejects them as written. See [Generate artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/). The Tasks sample sets `Development` in `Samples/Tasks/appsettings.json`; run the workspace command from its package directory (Yarn does this). `createBuilder` accepts the [configuration options](/arc/backend/typescript/configuration/) (`ArcOptions`). `build()` returns an `ArcApplication`, and `app.server` is its `ArcServer`. This TypeScript setup corresponds to C#‘s standalone `ArcApplication.CreateBuilder(args)`, `builder.AddCratisArc()`, `builder.Build()`, `app.UseCratisArc()`, `app.RunAsync()`. On Node, `app.run()` performs the standalone host step. For an Arc + Chronicle comparison, see [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/). Before a listener opens, `build()` checks the declared graph: missing service registrations, dependency cycles, singletons that capture shorter-lived services, and decorators placed where they have no effect. It never runs a service factory to do this. ## Discover artifacts from a folder `discover(folderUrl, { rootNamespace? })` imports every exported decorated class below the folder, in deterministic path order. * It accepts emitted `.js` files or loader-backed `.ts` files, not a mix of both. * It skips `dist`, `node_modules`, `given`, `for_*`, `index.*`, declaration files, symbolic links, co-located `*.proxy.ts` / `*.proxy.js` files, and React `.tsx` files. * It refuses a folder that contains the entry point, or an imported bootstrap that is itself calling discovery. Keep artifacts in a dedicated folder. * It derives each artifact’s namespace from its path below the folder, and reports a class discovered under two different namespaces. A folder rename changes the derived routes. [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) shows how to pin a route. ## Add artifacts explicitly Bundled production builds often cannot import a folder at runtime. Pass the classes instead: ```typescript builder.add(RegisterTask, RegisterTaskValidator, TaskItem); ``` `add()` rejects a class without an Arc decorator. Give explicitly added artifacts stable namespaces with `@command({ namespace: 'Tasks.Registration' })` and `@readModel({ namespace: 'Tasks.Listing' })`. If you rely on class names in routes, configure the bundler to keep them (`keepNames` in esbuild), or set explicit namespaces and paths. Besides commands, read models, and validators, `add()` and `discover()` recognize classes marked `@queryRenderer()`, `@readModelInterceptor()`, `@commandResponseValueHandler()`, `@identityDetailsProvider()`, and the lifetime decorators `@singleton()`, `@scoped()`, and `@transient()`. ## Register extension points The builder also registers services that change pipeline behavior: | Builder method | Registers | | --- | --- | | `services.addSingleton / addScoped / addTransient` | An application service; see [Dependency injection](/arc/backend/typescript/dependency-injection/) | | `addAuthorizationPolicy(name, policy)` | A named policy; see [Authorization policies](/arc/backend/typescript/core/authorization/) | | `addCommandResponseValueHandler(token)` | A handler for server-side return values; see [Response value handlers](/arc/backend/typescript/commands/response-value-handlers/) | | `addCommandKeyResolver(token)` | A rule that computes a command key; see [Command context](/arc/backend/typescript/commands/command-context/) | | `addCommandContextValuesProvider(token)` | Values attached to every command context | | `addReadModelForCommandResolver(token)` | A source for `commandReadModel(...)` parameters | | `addQueryRenderer(token)` | A renderer for provider-owned query results; see [Query renderers](/arc/backend/typescript/queries/renderers/) | | `addReadModelInterceptor(token)` | A read-model transform; see [Read-model interception](/arc/backend/typescript/queries/read-model-interception/) | After importing their packages, call `builder.withMongoDB(...)`, `builder.withDrizzle(...)`, or `builder.withChronicle(...)`. Importing the package adds its method to the builder. Each package also exports a function form, `withX(builder, options)`. Configuration from `appsettings.json` is available to Chronicle and MongoDB, but model classes, clients, and authentication must be provided explicitly. ## Run it, or mount it * `await app.run(...)` or `await app.start(...)` use the [standalone Node host](/arc/backend/typescript/core/). * `expressApp.use(cratisArc(app))`, `await fastifyApp.register(cratisArc, { arc: app })`, and `honoApp.use(cratisArc(app))` hand the application to a web framework. Import `cratisArc` from the matching adapter package. The host keeps listener ownership; call `await app.dispose()` at shutdown. See [Host adapters](/arc/backend/typescript/hosts/). * `app.fetch(request)` returns an Arc response or 404 for foreign paths; `app.handle(request)` returns `null` for fall-through. These Fetch methods alone do not establish edge-runtime compatibility: the core still imports Node runtime modules. A caller-owned `ServiceRegistry` passed in the options cannot be combined with builder service registrations. ## Related * [Hosting overview](/arc/backend/typescript/overview/) * [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) * [Decorator reference](/arc/backend/typescript/decorators/) # Static files and SPA fallback > Serve a built frontend beside Arc routes from the standalone Node host, and know which requests reach Arc, a file, or the fallback. A single-page application and its API often ship together. The standalone Node host can serve the built frontend on the same port as Arc, so you do not need a web framework or a second server for it. ## Serve a public directory main.ts (excerpt) ```typescript await app.run({ port: 3000, host: '127.0.0.1', pathBase: '/app', staticFiles: { root: 'public', defaultDocument: 'index.html' }, fallback: 'index.html' }); ``` `app` is a built `ArcApplication`; `runArc(server, options)` accepts the same options. With this configuration, `GET /app/` serves `public/index.html`, and `GET /app/dashboard` with `Accept: text/html` serves the same file. Requests outside `/app` return 404. `staticFiles.root` is resolved relative to the working directory. Startup fails if the directory is missing or the platform lacks `O_NOFOLLOW`. Nothing is served unless you configure `staticFiles`. Only put public files there: never credentials or private uploads. | `staticFiles` option | Effect | | --- | --- | | `root` | The public directory | | `defaultDocument` | File served for a directory request; `index.html` by default | | `contentTypes` | Extra or overriding content types, keyed by extension | | `wellKnown` | Relative names under `.well-known/` to expose, such as `.well-known/security.txt`; other dotpaths stay blocked | ## Which request goes where 1. **Arc endpoints win**, even on the wrong HTTP method: Arc answers with 405. API-prefix and `/.cratis` paths are reserved case-insensitively before static lookup and fallback. 2. **Public files** come next, for GET and HEAD only. A directory URL without a trailing slash is redirected with 301. 3. **The fallback** answers GET and HEAD requests whose `Accept` includes `text/html`, for extensionless paths outside Arc’s API prefix and `/.cratis`. An unknown API route never returns your SPA shell. If you configure no route prefix, disable the fallback when you need to reserve unknown root-level API paths. 4. Everything else gets a plain-text 404 `Not Found`. Requests using non-canonical spellings (percent escapes or repeated slashes) and endpoint paths in a different case return 404. ## Caching and content * The default document and the fallback send `Cache-Control: no-cache`. Static responses and 404s send `X-Content-Type-Options: nosniff`. * `If-None-Match` lists, weak ETags, and `*` produce 304; `If-None-Match` takes precedence over `If-Modified-Since`. * Range requests receive the full 200 response with `Accept-Ranges: none`. Media seeking is not supported. * Files are streamed up to their measured length. ## What the host refuses The host rejects path traversal, dotfiles, NUL bytes, backslashes, Windows alternate data streams and device names, and symbolic links that escape the resolved root or point at a dotfile. Symbolic links to ordinary files inside the root are allowed. Public files skip authentication Neither static files nor the fallback run Arc authentication or tenancy. XML and SVG files remain active same-origin content, so only serve trusted assets. Protect private files through authorized operations, not the public directory. ## Limits There is no directory listing, private file authorization, multiple static roots, static request path independent of `pathBase`, list of default documents, or byte-range response. ## Related * [Arc.Core and the standalone Node host](/arc/backend/typescript/core/) * [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) # Decorator reference > Every decorator and parameter descriptor you write on Arc for TypeScript artifacts, where it may go, what it changes, and the Arc on .NET attribute it corresponds to. Arc for TypeScript discovers what your application exposes from decorators on your classes, the way Arc on .NET uses [attributes](/arc/backend/csharp/attributes/). This is the whole set, grouped by concern. Decorators come from `@cratis/arc.core` unless the table says otherwise. Both standard decorators and legacy `experimentalDecorators` are supported. ## Artifacts | Decorator | Valid on | Effect | .NET | | --- | --- | --- | --- | | `@command({ namespace?, treatWarningsAsErrors? })` | class | Marks a command; Arc exposes it and calls its instance `handle()`. `treatWarningsAsErrors` sets the generated client’s flag | `[Command]` | | `@readModel({ namespace? })` | class | Marks a read model; its `@query()` static methods become queries | `[ReadModel]` | | `@query(options?, ...descriptors)` | public static method of a read model | Marks a query; `options` is `{ observable?, argumentsModel?, httpMethod?, treatWarningsAsErrors? }`. `httpMethod` takes a `QueryHttpMethod` member and, like `treatWarningsAsErrors`, sets a generated client preference | Static method on a `[ReadModel]` | | `@validator(Target)` | class extending `CommandValidator`, `QueryValidator`, `ConceptValidator`, or `ModelValidator` | Associates the validator with its exact target type | Discovered `AbstractValidator` | ## Fields | Decorator | Valid on | Effect | .NET | | --- | --- | --- | --- | | `@field(Type, ...)` from `@cratis/fundamentals` | public instance field | Declares the field and its wire type | The property’s CLR type | | `@key()` | field | The command key, and the storage identity of a read model | `[Key]` | | `@optional()` | field | The field may be omitted on input | Nullable reference type | | `@nullable()` | field | The field may be `null` | Nullable type | | `@defaultValue(value)` | field | Omitted input takes this value | Default parameter value | | `@enumeration(EnumObject)` | scalar field | Restricts the value to the members of an enum object | Enum-typed property | | `@derivedType('id')` from `@cratis/fundamentals` | class | Registers a polymorphic subtype, written as `_derivedTypeId` | Fundamentals derived types | ## Routing | Decorator | Valid on | Effect | .NET | | --- | --- | --- | --- | | `@path('/api/...')` | command or read-model class, `@query()` method | Overrides the derived route | `[Path]` | The counterpart of `[QueryHttpMethod]` is the `httpMethod` option: `@query({ httpMethod: QueryHttpMethod.Query })`, with `QueryHttpMethod` from `@cratis/arc.core`, makes the [generated client](/arc/backend/typescript/proxy-generation/#limits) send `QUERY`. `Get` and `Auto` are the other members. The option does not change the server, which accepts GET and `QUERY` unless `generatedApis.enableQueryHttpMethod` is `false`. There is no equivalent of `[FromRequest]`: arguments bind from the query string or `QUERY` body. ## Authorization | Decorator | Valid on | Effect | .NET | | --- | --- | --- | --- | | `@authorize()` | class, `@query()` method | Requires an authenticated caller | `[Authorize]` | | `@authorize('Policy')` or `@authorize({ policy?, roles?, schemes? })` | class, `@query()` method | Requires a policy, any listed role, and/or a named scheme | `[Authorize(Policy = ...)]` | | `@roles('A', 'B')` | class, `@query()` method | Requires at least one of the roles | `[Roles]` | | `@allowAnonymous()` | class, `@query()` method | Allows everyone | `[AllowAnonymous]` | Stacked declarations on the same target must all pass; an explicit query method declaration replaces its read-model class declaration. Without method decorators, the class declaration applies. Command authorization belongs on its class: decorators on `handle()`, `provide()`, any other command method, or a non-query static method fail at build. ## Services | Decorator | Valid on | Effect | .NET | | --- | --- | --- | --- | | `@inject(...tokens)` | command `handle()` or `provide()` | One token or marker per parameter, in order | Method parameter injection | | `@injectable(...tokens)` | class | Constructor dependencies; `static inject = [...] as const` is equivalent | Constructor injection | | `@singleton()`, `@scoped()`, `@transient()` | class | Registers a discovered service with that lifetime | Service lifetime conventions | ## Extension points | Decorator | Valid on | Effect | | --- | --- | --- | | `@commandResponseValueHandler()` | class implementing `CommandResponseValueHandler` | Registers a scoped [response value handler](/arc/backend/typescript/commands/response-value-handlers/) | | `@queryRenderer()` | class implementing `QueryRenderer` | Registers a scoped [query renderer](/arc/backend/typescript/queries/renderers/) | | `@readModelInterceptor()` | class implementing `ReadModelInterceptor` | Registers a scoped [read-model interceptor](/arc/backend/typescript/queries/read-model-interception/) | | `@identityDetailsProvider()` | class implementing `IdentityDetailsProvider` | Registers the [identity details provider](/arc/backend/typescript/identity/) | ## Parameter descriptors These are not decorators, but you pass them to `@query(...)` and `@inject(...)`: | Descriptor | Used in | Binds | | --- | --- | --- | | `argument(name, Type, { optional?, elementType? })` | `@query` | A named query argument | | `service(Token)` | `@query` | A service | | `queryOptions()` | `@query` | The request’s paging and sorting | | `abortSignal()` | `@inject` | The request’s `AbortSignal` | | `commandContext()` | `@inject` | The `CommandContext` | | `provided(Type)` | `@inject` on `handle()` | A value from `provide()`, by runtime type | | `commandReadModel(Type, { optional? })` | `@inject` | A read model loaded by the command key | | `mongoCollection(Model)` from `@cratis/arc.mongodb` | `service(...)` or `@inject` | The tenant’s MongoDB collection | | `drizzleReadModel(Model)`, `drizzleDatabase()` from `@cratis/arc.drizzle` | `service(...)` or `@inject` | The tenant’s read-only SQL handle or writable database | ## Chronicle From `@cratis/arc.chronicle` (experimental): | Decorator | Valid on | Effect | .NET | | --- | --- | --- | --- | | `@eventSourceType('Type', { concurrency? })` | command class | Default event source type for returned events | Command event metadata | | `@eventStreamType('Type', { concurrency? })` | command class | Default event stream type | Command event metadata | | `@eventStreamId('id', { concurrency? })` | command class | Default event stream ID | Command event metadata | | `@eventSourceDefinition(Source, 'Stream'?)` | command or aggregate class | Route events through a Chronicle event source definition and stream | [Event source definitions](/arc/backend/typescript/chronicle/commands/event-source-definitions/) | | `@eventSubject('subject')` | command class | Default compliance subject | Command event metadata | | `@notAudited()` | command field | Keeps the value out of the causation chain | `[NotAudited]` | Event types, projections, and Chronicle read models use the SDK’s own decorators, such as `@eventType()` from `@cratis/chronicle/events`. ## Related * [Code analysis](/arc/backend/typescript/code-analysis/) for the ESLint rules that check decorator usage * [Troubleshooting](/arc/backend/typescript/troubleshooting/) for decorator compiler settings # Dependency injection > Register services with a lifetime, inject them into commands, queries, and validators by class or token, and rely on the build to check the graph and on scopes to dispose what they create. Handlers need repositories, clocks, and clients, and those need to be created once, per request, or every time, and cleaned up afterward. Arc has its own small service registry built for that: you register a class or a token with a lifetime, list what each method needs, and the build checks the whole graph before a listener opens. Arc for TypeScript does not integrate with another dependency injection container. ## Register services The [Tasks bootstrap](/arc/backend/typescript/getting-started/#see-what-started-the-server) registers `Tasks` as a singleton shared by its command and queries. It installs generated metadata before discovering the artifacts, then builds the application: main.ts (excerpt) ```typescript import { ArcApplication } from '@cratis/arc.core'; import { Tasks } from './Features/Tasks/Tasks.js'; const builder = ArcApplication.createBuilder(); builder.services.addSingleton(Tasks); ``` | Registration | Lifetime | | --- | --- | | `addSingleton(Tasks)` | One instance for the application | | `addScoped(Tasks)` | One instance per execution scope: an HTTP request, a direct call, or an observable subscription | | `addTransient(Tasks)` | A fresh instance per resolution | Each method self-binds a class, or takes a second argument: a concrete class for an abstract class token, or a factory `(scope) => instance`. An abstract class is a good token because it exists at runtime; an interface does not. For a value with no class, create a token with `serviceToken('name')`. Instead of registering explicitly, decorate a discovered class with `@singleton()`, `@scoped()`, or `@transient()`. Without one of those, add it to `builder.services`. ## Inject services | Where | How | | --- | --- | | Command `handle()` or `provide()` | Generated metadata, or `@inject(Tasks)` with one token per parameter in order | | Query method | Generated metadata, or `service(Tasks)` in the ordered `@query(...)` descriptors | | Class constructor | `@injectable(OtherService)` or `static inject = [OtherService] as const` | | Validator constructor | The same as a class constructor | `builder.build()` checks declared artifact dependencies for missing registrations, cycles, and singletons that capture scoped or transient services, before opening a listener. It never runs a service factory to do so. ## Decorator modes Two decorator modes are tested: * **Standard decorators**, used by the sample, need [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) or explicit token lists. The compiler cannot reflect erased parameter types. * **Legacy `experimentalDecorators` with `emitDecoratorMetadata`**: a decorated `@inject()` method, `@query()` method, or `@injectable()` class can infer **class-valued** parameters from `design:paramtypes`. Interfaces, `Object`, missing metadata, and erased generics cannot be inferred; registration fails with a diagnostic naming the member. An explicit token list always wins. In standard mode, `@inject(...)` and `@query(...)` also type-check their parameters. TypeScript error TS1241 usually means the tokens and parameters do not match; see [Troubleshooting](/arc/backend/typescript/troubleshooting/#ts1241-unable-to-resolve-signature-of-method-decorator). ## Low-level services Low-level definitions register services as `{ token, lifetime, factory }` in the `services` option, declare them with `handlerDependencies` or `validatorDependencies`, and resolve them with `currentServices()`: ```typescript import { ArcServer, currentServices, defineCommand, serviceToken, ServiceLifetime, Severity } from '@cratis/arc.core'; import { z } from 'zod'; const journal = serviceToken<{ append(text: string): void; entries: string[] }>('journal'); let created = 0; const server = new ArcServer({ services: [{ token: journal, lifetime: ServiceLifetime.Scoped, factory: () => { created++; const entries: string[] = []; return { entries, append: (text: string) => { entries.push(text); } }; } }], commands: [defineCommand({ name: 'Write', schema: z.object({ text: z.string() }), handlerDependencies: [journal], handle: async ({ text }) => { const service = await currentServices().resolve(journal); service.append(text); return service.entries; } })] }); const context = { correlationId: crypto.randomUUID(), principal: undefined, tenantId: 'acme', signal: new AbortController().signal, allowedSeverity: Severity.Warning }; const validation = await server.validateCommand('Write', { text: 'hello' }, context); const result = await server.executeCommand('Write', { text: 'hello' }, context); console.log(validation.isSuccess, result.response, created); // true ['hello'] 1 await server.dispose(); ``` The validation-only call checks that the journal is registered but never constructs it. `validatorDependencies` are preflighted **and constructed** before validators, including on `/validate`; `handlerDependencies` are preflighted, then constructed only after validation succeeds. A missing dependency reports reason `dependencyUnavailable`, not `rule`. A factory declares its own `dependencies` for preflight and resolves them with its `resolver` argument; a scoped or transient factory also receives the execution context as its second argument. You can register a singleton `instance` instead of a factory; Arc does not dispose a caller-owned instance. Register each token once. ## Scopes and disposal * Arc creates and disposes a scope for every HTTP request or direct call, including denied or failed executions. * Scoped and transient instances created by factories belong to their scope. Arc calls `Symbol.asyncDispose` or `Symbol.dispose` once, in reverse creation order, even after an exception. A disposal failure turns a successful result into a failure. Disposal is not a rollback of external effects. * Singletons belong to the registry and are disposed when you call `await app.dispose()` (or `await server.dispose()`). A singleton factory receives a frozen registry-lifetime context with only a `signal`, and never a request identity; do not capture request data in a singleton. * A factory alias of an existing singleton or caller-owned instance does not take ownership. Returning an object owned by another scope fails with a service dependency error. * If you pass your own `ServiceRegistry` in `services`, you own its disposal; the server does not dispose it. It cannot be combined with builder registrations. A host integration with work outside Arc’s execution tracker can register a shutdown participant on `server.services` (or on its own `ServiceRegistry`): ```typescript const remove = server.services.addShutdownParticipant({ stop() { /* reject new work and request cancellation; do not close dependencies */ }, async drain() { /* await every previously admitted operation and its cleanup */ } }); // Call remove() only if the integration is detached before shutdown begins. ``` Arc freezes the participant list when shutdown starts; registrations after that point fail. With participants, registry disposal closes new execution and scope admission, releases observable producers and closes transports, invokes **every** `stop()` (even if one throws), then awaits **every** `drain()`. Only after all drains settle does it join admitted execution and delivery work, dispose scopes, and dispose singletons. A server registers its observable resources with the registry before shutdown, so direct registry disposal and singleton failure use the same order. A caller-owned registry must be disposed explicitly by its owner: use [`shutdownArcHost`](/arc/backend/typescript/core/#shut-down-without-dropping-work) to hand it to coordinated host shutdown. Participant, transport, and service-disposal errors are reported together. There is no timeout that proceeds to dispose live dependencies: cancellation is cooperative. Participants stop after producer release has settled or its one-second cancellation window has passed; a producer that has not returned by then is reported as a shutdown failure once the drains finish. Producer cleanup must not wait for participant `stop()` or `drain()`. A participant may use an admitted scope while draining, but cannot admit a new scope or execution. Existing sessions stop emitting before participants run; do not depend on new emissions during stop or drain. Removing a participant after shutdown starts does not remove it from that shutdown. With no participants, the **first** server shutdown closes observable transport and sessions before it begins registry disposal; service admission remains open until then, as before. Late participant registration is still rejected. Subsequent and concurrent `dispose()` calls join the same outcome without repeating a phase, including when that outcome is a rejection. A singleton factory that fails poisons the registry and triggers this same sequence. A failed Arc operation does not wait for participant drain before returning its result: a participant may be awaiting that very result. Do not await `registry.dispose()` from inside its own participant’s stop/drain (including work launched by stop), handler, factory, or disposer; it rejects with a service dependency error rather than deadlocking. Stop singleton background loops when the registry signal aborts, then join them in the singleton’s disposer. When supplying a registry to a server, the host still owns its disposal; `server.dispose()` does not dispose that registry. For every scope, `scope.identity`, the `execution` argument passed to scoped **and transient** factories, and `currentContext()` during their construction use the same frozen, plain creation-time snapshot of the declared execution context fields, including fields supplied by class getters (such as `tenantId` and `signal`). This remains true even when a factory is first resolved during borrowed work or an outer scope’s service is resolved from a nested invocation. The snapshot retains the caller’s **original principal object reference**, not a clone or frozen copy. Its mutable roles and claims remain mutable for ordinary requests; changing the caller’s context fields after scope creation does not change the scoped tenant, correlation ID, or signal. Outside factory construction, `currentContext()` continues to reflect the current execution. Built-in Chronicle, MongoDB, and Drizzle factories also read this stable scope identity. Nested commands and queries keep their causal dependency ancestry for cycle detection but get their own execution identity and scoped lifetime guard; the same scoped token in two independent nested scopes is not a cycle. ## Borrow a scope in a host integration A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Only scopes created with `server.services.createScope(context)` can be borrowed; Arc’s own request scopes skip the principal copy. Dispose the host-created scope yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. The callback’s ambient `currentContext()` receives that copy, the invocation’s correlation ID, and a signal linked to the scope’s signal and any additional signal; factories instead see the scope snapshot. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation’s ambient context, not to the scope or its factories: both `execution` and `currentContext()` inside a scoped or transient factory use the scope’s original signal, correlation ID, and principal reference. A scoped instance created during one invocation retains those values when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope’s stable creation-time factory context. If a singleton factory fails before a borrowed invocation drains, `runInScope` rejects with a service dependency error rather than returning the callback’s value; registry shutdown waits for the invocation without being joined inside it. Borrowing requires a strictly plain-data principal throughout its roles, claims, and extra fields: ordinary objects (including null-prototype objects), arrays, and primitive values, with only own enumerable string properties and no accessors. Arrays’ built-in `length` is allowed. Symbol keys or values, non-enumerable properties, getters, functions, `Map`, `Set`, `Date`, class instances, invalid identity fields, or nesting beyond the supported depth make the scope non-borrowable. Ordinary requests still use the original principal reference and their creation-time context fields; `runInScope` rejects a non-borrowable scope before calling back. This is a trusted host API, **not an authorization mechanism** or a way to authenticate a principal. Use the normal command or query pipeline for authorization; do not expose scope creation or borrowed execution to untrusted callers. ## Related * [Build an application](/arc/backend/typescript/core/getting-started/) * [Testing](/arc/backend/typescript/testing/), where scenarios register fakes # Get started with Arc for TypeScript > Run the Tasks sample, register a task with a command, watch Arc reject bad input, and read the task back with a query, all without writing an HTTP route. Say you need a small task API: register a task, list the tasks, and refuse a task without a title. In a plain Node.js server that means a route per operation, body parsing, a validation response format, status-code mapping, and a client that has to know all of it. Every endpoint repeats the same plumbing, and every rename risks breaking the client. With Arc you write the parts that are yours: a command class that registers a task, a validator with the rules, and a read model with the queries. Arc serves them over HTTP, checks input before your code runs, and wraps every answer in the same result shape. In this walkthrough you run the Tasks sample from this repository and talk to it with `curl`. By the end you have a server on `127.0.0.1:3000` that accepts a command, rejects invalid input before the handler runs, and serves the tasks you stored. No event store or database is involved. Source preview No Arc for TypeScript server package is published to npm yet. Work inside a clone of this repository; the API may still change. Check the [capability reference](/arc/backend/typescript/reference/capabilities/) before you rely on a feature in a larger application. To start a project of your own, follow [Create an application](/arc/backend/typescript/getting-started/create-an-application/). ## Build and run the sample You need Node.js 22.19 or later, Git, Corepack, and `curl`. Node.js 25 and later no longer include Corepack; if `corepack enable` reports that the command is not found, run `npm install --global corepack` first. ```bash git clone https://github.com/Cratis/Arc.TypeScript.git cd Arc.TypeScript corepack enable yarn install yarn build yarn workspace @cratis/arc.core.sample.tasks start ``` The server listens on `127.0.0.1:3000` and keeps its tasks in memory, so a restart clears them. Set `PORT` to use another port. It runs until you press Ctrl+C or send SIGTERM, then closes its connections gracefully. Leave it running and open a second terminal for the rest of this page. ## Register a task Send the `RegisterTask` command with any UUID and a title: ```bash curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task \ -H 'content-type: application/json' \ -d '{"id":"1a638f8e-4444-4444-8888-a0b10cdd9977","title":"Write a guide"}' ``` The answer is HTTP 200 with a command result. The correlation ID differs on every request: ```json {"correlationId":"0c2d6872-c3cb-4a84-af93-1084caa4d22d","isAuthorized":true,"validationResults":[],"exceptionMessages":[],"exceptionStackTrace":"","authorizationFailureReason":"","isValid":true,"hasExceptions":false,"isSuccess":true,"response":"1a638f8e-4444-4444-8888-a0b10cdd9977"} ``` Nobody wrote that route. Arc derived `/api/tasks/registration/register-task` from the folder the command lives in and its class name. It parsed the body against the command’s declared fields, turned the ID string into a typed `TaskId`, ran the validators, and called the command’s `handle()` method. The `response` is the ID that `handle()` returned, encoded back to a string. The flags (`isSuccess`, `isValid`, `isAuthorized`, `hasExceptions`) are the same on every command, so a client checks one shape everywhere. ## Break the rules Now send a task with an empty title: ```bash curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task \ -H 'content-type: application/json' \ -d '{"id":"2b638f8e-4444-4444-8888-a0b10cdd9977","title":""}' ``` The answer is 400, and `validationResults` holds one entry: ```json {"severity":3,"message":"A title is required","members":["title"],"reason":"rule"} ``` That message comes from the sample’s validator, and `members` tells a form which field to mark. The handler never ran, so nothing was stored. Leave `title` out of the body entirely and you get a different 400: one result with reason `malformedRequest` and no members. Arc separates a request with the wrong *shape* (a missing field, a wrong type, broken JSON) from a request that breaks a *rule* a user can fix. Only rules carry a message meant for a person. A frontend often wants to check a rule before the user presses Save. Every command has a second route for that: `POST /validate` runs authorization and validation, then stops: ```bash curl -X POST http://127.0.0.1:3000/api/tasks/registration/register-task/validate \ -H 'content-type: application/json' \ -d '{"id":"2b638f8e-4444-4444-8888-a0b10cdd9977","title":"!Loud"}' ``` The answer is 400 with `A title cannot begin with an exclamation mark` for the member `title`. That rule belongs to the task title itself, wherever it appears. [Your first command](/arc/backend/typescript/getting-started/your-first-command/) shows where each rule lives. ## Read what you registered Queries are GET requests: ```bash curl http://127.0.0.1:3000/api/tasks/listing/all-tasks curl 'http://127.0.0.1:3000/api/tasks/listing/task-by-id?id=1a638f8e-4444-4444-8888-a0b10cdd9977' ``` The first answer’s `data` is an array holding `{ "id": "1a638f8e-4444-4444-8888-a0b10cdd9977", "title": "Write a guide" }`. The second answers with that object directly. Arc bound `id` from the query string by name and converted it to a `TaskId`; a missing or invalid UUID answers 400 with `malformedRequest`. An ID nobody registered answers 200 without a `data` property: absence is an answer, not an error. Add `?pageSize=1&sortBy=title` to the first URL and Arc pages and sorts the list for you, reporting the totals in `paging`. The query method itself only returns an array. ## See what started the server The whole entry point is [`Samples/Tasks/main.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/main.ts): Samples/Tasks/main.ts ```typescript import { ArcApplication } from '@cratis/arc.core'; import { Tasks } from './Features/Tasks/Tasks.js'; import { metadata } from './Features/generatedMetadata.js'; // The Yarn start script selects Development; appsettings.json only opts into fixture providers. const builder = ArcApplication.createBuilder(); builder.useGeneratedMetadata(metadata); builder.services.addSingleton(Tasks); await builder.discover(new URL('./Features/', import.meta.url)); export const app = await builder.build(); await app.run({ port: Number(process.env.PORT ?? 3000) }); ``` Each line has one job: | Line | What it does | | --- | --- | | `createBuilder()` | Starts an Arc application and reads optional `appsettings.json` (`Cratis:Arc`) and `Cratis__...` environment variables | | `useGeneratedMetadata(metadata)` | Installs parameter and return-type information that the proxy generator extracted from the source, because TypeScript erases types at runtime | | `services.addSingleton(Tasks)` | Registers the in-memory store that the command and queries receive | | `discover(...)` | Imports the modules under `Features/` (skipping `for_*` spec folders) and picks up commands, read models, and validators by their decorators | | `build()` | Checks the whole graph (every injected service registered, no lifetime mismatches, no misplaced decorators) before any listener opens | | `run(...)` | Starts the standalone Node.js host and maps every route | If you forget to register `Tasks`, `build()` throws `Missing service: Tasks` at startup, instead of the first request failing in production. The sample’s [`appsettings.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/appsettings.json) sets `Cratis:Arc:Development` to `true` to enable fixture providers. The `start` script separately sets `DOTNET_ENVIRONMENT=Development` using Yarn 4’s cross-platform script shell. That environment enables anonymous discovery and exception details for local development; do not use this start script on an exposed host. [Configuration](/arc/backend/typescript/configuration/) lists every setting. To serve the same artifacts from Express, Fastify, or Hono instead of the standalone host, you keep the builder and hand its routes to the framework’s adapter; see the [hosting overview](/arc/backend/typescript/overview/). ## Look at what the server describes The sample’s Development host describes itself anonymously. `GET /.cratis/commands` and `GET /.cratis/queries` list every operation with its route and the JSON Schema of its input, and `GET /openapi.json` returns an OpenAPI 3.1 document. Outside Development these endpoints follow the [discovery access policy](/arc/backend/typescript/introspection/#production-access): ```bash curl http://127.0.0.1:3000/.cratis/commands ``` The first entry names `RegisterTask`, its route, and a schema that requires `id` as a UUID and `title` as a string. [Introspection](/arc/backend/typescript/introspection/) and [OpenAPI](/arc/backend/typescript/open-api/) cover both. ## Recap You started a server with no routing code. A command registered a task, Arc refused an empty title with a message for the right field, a query returned the stored task, and paging and sorting came for free. The entry point is six statements, and `build()` checked the wiring before the server accepted a request. ## If you know Arc on .NET The concepts carry over; the spelling is TypeScript: | Arc on .NET | Arc for TypeScript | | --- | --- | | `ArcApplication.CreateBuilder(args)` | `ArcApplication.createBuilder()` | | `[Command]` record with `Handle()` | `@command()` class with `handle()` | | `[ReadModel]` record with static query methods | `@readModel()` class with static `@query()` methods | | Assembly discovery | `builder.discover(folderUrl)` or `builder.add(...)` | | `app.UseCratisArc()` and `RunAsync()` | `app.run()` on the standalone host, or a framework adapter | [Coming from Express and NestJS](/arc/backend/typescript/coming-from-express-and-nestjs/) compares Arc with the Node.js code you may write today. ## Next step Open the sample and read it file by file in [Your first command](/arc/backend/typescript/getting-started/your-first-command/). After that, [Continue in the browser](/arc/backend/typescript/getting-started/continue-in-the-browser/) generates a typed client and calls this server from a React page. When you want a project of your own, [Create an application](/arc/backend/typescript/getting-started/create-an-application/) sets one up from an empty folder. # Continue in the browser > Generate typed proxies from the Tasks sample, call its command from a React form, and watch an observable query update the page without a reload. The Tasks server answers `curl`. A real user needs a page, and the usual next step is a hand-written `fetch` for every endpoint: a URL string, a body type that mirrors the server’s fields, and a copy of the validation rules so the form can complain before submitting. All of that drifts the first time someone renames a field on the server. Arc generates that client for you. The proxy generator reads the same classes the server serves and writes a typed TypeScript class per command and query, with the route, the fields, the client-safe validation rules, and a React hook. You write the page; the proxies keep it in step with the server. In this lesson you create a small Vite and React app beside your clone, generate the Tasks proxies into it, and build one page that registers tasks and shows a live list. By the end, a task you register appears in the list without a reload. ## Before you start Finish [Get started](/arc/backend/typescript/getting-started/) first, and keep the Tasks server running on `127.0.0.1:3000`. You also need `npm`. The browser side uses the published client packages `@cratis/arc` and `@cratis/arc.react` 22.45.0, the same versions the Library sample uses. They are the Arc frontend packages, released from the Arc repository; the generator in this repository targets them. ## Create the web app From the folder that contains your `Arc.TypeScript` clone, create a sibling folder: ```bash mkdir -p tasks-web/src/generated cd tasks-web ``` Create `package.json`: tasks-web/package.json ```json { "name": "tasks-web", "private": true, "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { "@cratis/arc": "22.45.0", "@cratis/arc.react": "22.45.0", "@cratis/fundamentals": "7.22.0", "react": "^19.2.0", "react-dom": "^19.2.0", "reflect-metadata": "0.2.2", "tsyringe": "^4.10.0" }, "devDependencies": { "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "typescript": "^7.0.2", "vite": "^8.2.2" } } ``` Create `tsconfig.json`. The generated models use legacy decorators, so `experimentalDecorators` and `emitDecoratorMetadata` are required: tasks-web/tsconfig.json ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "skipLibCheck": true, "noEmit": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "experimentalDecorators": true, "emitDecoratorMetadata": true, "types": ["vite/client"] }, "include": ["src", "vite.config.ts"] } ``` Create `vite.config.ts`. The dev server forwards API calls and the live-query connection to the Tasks server, so the browser talks to one origin: tasks-web/vite.config.ts ```typescript import { defineConfig } from 'vite'; export default defineConfig({ server: { port: 5173, strictPort: true, proxy: { '/api': { target: 'http://127.0.0.1:3000' }, '/.cratis': { target: 'http://127.0.0.1:3000', ws: true } } } }); ``` Create `index.html`: tasks-web/index.html ```html Tasks
``` Then install: ```bash npm install ``` ## Generate the proxies Go back to your clone and run the generator against the Tasks sample, writing into the web app’s `src/generated` folder: ```bash cd ../Arc.TypeScript node Source/Tools/ProxyGenerator/dist/cli.js \ --project "$PWD/Samples/Tasks/tsconfig.json" \ --artifacts "$PWD/Samples/Tasks/Features" \ --output "$PWD/../tasks-web/src/generated" \ --use-generated-metadata \ --use-proxy-file-suffix ``` The generator prints `Generated 7 changed file(s)`. It also prints one note, `Server-only validator rule on value`, for the `TaskTitleValidator` rule: a `must(...)` callback is code, and code does not travel to the browser. You now have: ```text src/generated/Tasks/Listing/AllTasks.proxy.ts src/generated/Tasks/Listing/ObserveAllTasks.proxy.ts src/generated/Tasks/Listing/TaskById.proxy.ts src/generated/Tasks/Listing/TaskItem.proxy.ts src/generated/Tasks/Listing/index.ts src/generated/Tasks/Registration/RegisterTask.proxy.ts src/generated/Tasks/Registration/index.ts ``` The generator never imported or ran the server. It read the TypeScript source with the compiler API. Here is the heart of `RegisterTask.proxy.ts`: ```typescript export class RegisterTaskValidator extends CommandValidator { constructor() { super(); this.ruleFor(c => c.title).notEmpty().withMessage('A title is required'); this.ruleFor(c => c.title).maxLength(100).withMessage('A title can have at most 100 characters'); } } export class RegisterTask extends Command implements IRegisterTask { readonly route: string = '/api/tasks/registration/register-task'; readonly validation: CommandValidator = new RegisterTaskValidator(); ``` The route matches the server’s. The `notEmpty` and `maxLength` rules came across from `RegisterTaskValidator`, word for word. The `TaskId` concept arrives as a `Guid` and `TaskTitle` as a `string`, because concepts travel as their underlying value. Do not edit these files; regenerate them when the server changes. ## Build the page Create `src/TaskBoard.tsx`: tasks-web/src/TaskBoard.tsx ```tsx import { useState, type FormEvent } from 'react'; import { Guid } from '@cratis/fundamentals'; import { RegisterTask } from './generated/Tasks/Registration/RegisterTask.proxy'; import { ObserveAllTasks } from './generated/Tasks/Listing/ObserveAllTasks.proxy'; export function TaskBoard() { const [registerTask, setValues] = RegisterTask.use(); const [tasks] = ObserveAllTasks.use(); const [title, setTitle] = useState(''); const [message, setMessage] = useState(''); const submit = async (event: FormEvent) => { event.preventDefault(); setValues({ id: Guid.create(), title }); const result = await registerTask.execute(); if (result.isSuccess) { setTitle(''); setMessage('Task registered.'); } else { setMessage(result.validationResults.map(item => item.message).join(' ')); } }; return

Tasks

void submit(event)}> setTitle(event.target.value)} />

{message}

{tasks.data.length} tasks

    {tasks.data.map(task =>
  • {task.title}
  • )}
; } ``` Two hooks do the work: * `RegisterTask.use()` returns the command instance and a setter. `setValues` writes the fields onto the instance immediately, so the next line can `execute()` it. The result has the same `isSuccess` and `validationResults` you saw with `curl`. * `ObserveAllTasks.use()` subscribes to the observable query and returns its current result. `tasks.data` starts as an empty array and re-renders the component every time the server’s list changes. Create `src/main.tsx`: tasks-web/src/main.tsx ```tsx import 'reflect-metadata'; import { createRoot } from 'react-dom/client'; import { Arc } from '@cratis/arc.react'; import { TaskBoard } from './TaskBoard'; createRoot(document.getElementById('root')!).render( ); ``` `` gives every hook below it the same configuration: the API origin (here the page’s own origin, which Vite forwards) and how live queries travel. `` uses the server-sent events hub by default. It accepts anonymous connections, and the Tasks sample’s observable query permits anonymous subscriptions. See [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/) for its control-request security model. ## Run it In the `tasks-web` folder, check the types and start the dev server: ```bash npm run typecheck npm run dev ``` Open . The list shows any tasks you registered with `curl` earlier. Now try three titles: | You type | The status line shows | What happened | | --- | --- | --- | | Nothing | `A title is required` | The proxy’s copy of the rule failed in the browser; no request was sent | | `!Loud` | `A title cannot begin with an exclamation mark` | The browser had no copy of this rule, so the server checked it and answered 400 | | `Try the browser` | `Task registered.` | The command succeeded, and the list grows by one without a reload | The last row is the observable query at work. The SSE hub subscribed to `observeAllTasks` when the page loaded. When `handle()` called `tasks.register(...)`, the sample’s `BehaviorSubject` emitted the new list, Arc pushed it over the open connection, and the hook re-rendered the page. Register a task with `curl` from another terminal and it appears in the browser too. ## Recap You generated a typed client from the server’s source, called a command from a form with `RegisterTask.use()`, and kept a list live with `ObserveAllTasks.use()`. Simple rules ran in the browser, rules written as code ran on the server, and both reported through the same result shape. When the server’s command or query changes, run the generator again and TypeScript tells you which parts of the page no longer fit. ## Next step * [Proxy generation](/arc/backend/typescript/proxy-generation/) covers every generator option, including `--watch` for a development loop. * [Observable queries](/arc/backend/typescript/queries/observable-queries/) explains sources, snapshots, paging, and authorization for live queries. * [Explore the Library sample](/arc/backend/typescript/getting-started/library-sample/) shows a larger React app with paging, authorization, and Chronicle. * The shared [Arc frontend documentation](/arc/frontend/) covers the client packages in depth. # Create an Arc application from an empty folder > Build the Arc packages from a clone, install them into a new Node.js project, and run your own command and query with generated metadata and a watch loop. You have seen the Tasks sample and want a project of your own. `npm install @cratis/arc.core` fails, because nothing from this repository is published to npm yet. This guide takes an empty folder to a running Arc server using packages you build from a clone. When you finish, a small notes API listens on `127.0.0.1:3000`. `POST /api/notes/writing/write-note` stores a note, refuses one without text, and `GET /api/notes/listing/all-notes` returns what you stored. The server reloads when you edit the code. Source preview Arc for TypeScript is an unpublished source preview, and its API may still change. Check the [capability reference](/arc/backend/typescript/reference/capabilities/) before you rely on a feature in a larger application. ## Before you start You need Node.js 22.19 or later, Git, Corepack, npm, and `curl`. Node.js 25 and later no longer include Corepack; if `corepack enable` reports that the command is not found, run `npm install --global corepack` first. There are two ways to consume packages that are not on npm: | Path | Your project lives | Use it when | | --- | --- | --- | | [Packed tarballs](#build-and-pack-the-packages) | In any folder, with its own `package.json` and lockfile | You want a separate repository. This guide follows this path | | [Inside the clone](#work-inside-the-clone-instead) | Under `Samples/` in your clone of this repository | You are experimenting, or you change Arc itself while you build on it | A dependency written as `workspace:^`, as in the samples, resolves only inside this repository’s Yarn workspace. In a project of your own it fails at install. ## Build and pack the packages Clone and build the repository: ```bash git clone https://github.com/Cratis/Arc.TypeScript.git cd Arc.TypeScript corepack enable yarn install yarn build ``` Pack the core and the proxy generator into a folder next to the clone: ```bash mkdir -p ../arc-packages yarn workspace @cratis/arc.core pack --out "$PWD/../arc-packages/arc.core.tgz" yarn workspace @cratis/arc.proxygenerator pack --out "$PWD/../arc-packages/arc.proxygenerator.tgz" ``` Use `yarn pack`, not `npm pack`. Yarn rewrites the internal `workspace:^` dependencies to version ranges as it packs. `npm pack` copies them unchanged, and the tarball then fails to install. Pack an adapter or integration the same way when you need it; [Packages](/arc/backend/typescript/reference/packages/) lists the workspace names. After you pull a newer version of the repository, run `yarn build` and the pack commands again. Then repeat the two `npm install` commands from the next section in your project. A plain `npm install` keeps the package contents it installed before, because the tarball path and version have not changed. ## Create the project Start with an empty folder next to the packages: ```bash cd .. mkdir my-arc-app cd my-arc-app ``` Create `package.json`: package.json ```json { "name": "my-arc-app", "private": true, "type": "module", "engines": { "node": ">=22.19.0" }, "scripts": { "generate": "node generate.mjs", "build": "npm run generate && tsc6", "start": "node dist/main.js", "dev": "tsx watch main.ts" } } ``` Arc packages are ES modules only, so the project needs `"type": "module"`. Install the tarballs and their peer dependencies, then the development tools: ```bash npm install ../arc-packages/arc.core.tgz @cratis/fundamentals@^7.19.6 @opentelemetry/api@^1.9.0 npm install --save-dev ../arc-packages/arc.proxygenerator.tgz typescript@npm:@typescript/typescript6@^6.0.2 @types/node@^22 tsx ``` npm records the tarballs as `file:../arc-packages/...` dependencies. `typescript` is pinned to TypeScript 6, the compiler this repository and the proxy generator use. That package installs its compiler command as `tsc6`, which is why the `build` script calls `tsc6`. Recent npm versions may report that they blocked install scripts for `esbuild` (and `fsevents` on macOS); `tsx` runs without them. Create `tsconfig.json`: tsconfig.json ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022", "ESNext.Decorators"], "types": ["node"], "strict": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "rootDir": ".", "outDir": "dist" }, "include": ["main.ts", "Features/**/*.ts"], "exclude": ["Features/**/*.proxy.ts", "Features/**/*.tsx"] } ``` | Setting | Why | | --- | --- | | No `experimentalDecorators` | Compiles Arc’s decorators as standard TC39 decorators, as the samples do. `ESNext.Decorators` in `lib` declares the `Symbol.metadata` they use. Legacy decorators also work; see [Decorator modes](/arc/backend/typescript/dependency-injection/#decorator-modes) | | `NodeNext` | Node runs the emitted ES modules directly. Write relative imports with a `.js` extension. The samples use `ESNext` with `Bundler`, which also works; see [NodeNext or Bundler?](/arc/backend/typescript/troubleshooting/#nodenext-or-bundler) | | `verbatimModuleSyntax` | Keeps imports as written; use `import type` for an import that is only a type | | `include` / `exclude` | Compiles the entry point and backend artifacts; excludes co-located browser proxies and React components from the server build | ## Write a command and a query Arc discovers artifacts under one folder and derives each route from the artifact’s folder and class name. Lay the notes feature out as vertical slices, one folder per behavior: ```text my-arc-app/ ├── Features/ │ └── Notes/ │ ├── NoteId.ts │ ├── NoteText.ts │ ├── Notes.ts │ ├── Writing/ │ │ └── Writing.ts │ └── Listing/ │ └── Listing.ts ├── generate.mjs ├── main.ts ├── package.json └── tsconfig.json ``` Give the domain values their own types, as the Tasks sample does: Features/Notes/NoteId.ts ```typescript import { ConceptAs, Guid } from '@cratis/fundamentals'; export class NoteId extends ConceptAs { static readonly valueType = Guid; } ``` Features/Notes/NoteText.ts ```typescript import { ConceptAs } from '@cratis/fundamentals'; export class NoteText extends ConceptAs { static readonly valueType = String; } ``` The command and its validator form the writing slice: Features/Notes/Writing/Writing.ts ```typescript import { field } from '@cratis/fundamentals'; import { command, CommandValidator, validator } from '@cratis/arc.core'; import { NoteId } from '../NoteId.js'; import { NoteText } from '../NoteText.js'; import { Notes } from '../Notes.js'; @command() export class WriteNote { @field(NoteId) id!: NoteId; @field(NoteText) text!: NoteText; handle(notes: Notes): NoteId { notes.add(this.id, this.text); return this.id; } } @validator(WriteNote) export class WriteNoteValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.text).notEmpty().withMessage('A note needs text'); this.ruleFor(command => command.text).maxLength(200).withMessage('A note can have at most 200 characters'); } } ``` The read model and its query form the listing slice: Features/Notes/Listing/Listing.ts ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel } from '@cratis/arc.core'; import { NoteId } from '../NoteId.js'; import { NoteText } from '../NoteText.js'; import type { Notes } from '../Notes.js'; @readModel() export class Note { @field(NoteId) id: NoteId; @field(NoteText) text: NoteText; constructor(id: NoteId, text: NoteText) { this.id = id; this.text = text; } @query() static allNotes(notes: Notes): Note[] { return notes.all(); } } ``` Both slices use one in-memory store. It creates each `Note` through the constructor: Features/Notes/Notes.ts ```typescript import { Note } from './Listing/Listing.js'; import { NoteId } from './NoteId.js'; import { NoteText } from './NoteText.js'; /** In-memory storage for this guide; a restart clears it. */ export class Notes { readonly #items = new Map(); add(id: NoteId, text: NoteText): void { this.#items.set(id.toString(), new Note(id, text)); } all(): Note[] { return [...this.#items.values()]; } } ``` Finally, the entry point: main.ts ```typescript import { ArcApplication } from '@cratis/arc.core'; import { metadata } from './Features/generatedMetadata.js'; import { Notes } from './Features/Notes/Notes.js'; const builder = ArcApplication.createBuilder(); builder.useGeneratedMetadata(metadata); builder.services.addSingleton(Notes); await builder.discover(new URL('./Features/', import.meta.url)); const app = await builder.build(); await app.run({ port: Number(process.env.PORT ?? 3000) }); ``` `handle(notes: Notes)` and `allNotes(notes: Notes)` carry no `@inject(...)`. TypeScript erases parameter types when it compiles, so Arc reads them from the generated metadata module that `main.ts` imports. That module does not exist yet. ## Generate the metadata `arc-proxygenerator` reads your artifacts with the TypeScript compiler and writes the metadata module. It also writes client proxies, for a frontend you may add later. The CLI takes absolute paths and needs an existing output folder. Use `Features` as both artifacts and output, as the samples do; `.proxy.ts` keeps browser files distinct from backend modules: generate.mjs ```javascript import { spawnSync } from 'node:child_process'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; const path = relative => fileURLToPath(new URL(relative, import.meta.url)); const cli = fileURLToPath(new URL('./cli.js', import.meta.resolve('@cratis/arc.proxygenerator'))); const result = spawnSync(process.execPath, [cli, '--project', path('./tsconfig.json'), '--artifacts', path('./Features'), '--output', path('./Features'), '--use-proxy-file-suffix', '--metadata', path('./Features/generatedMetadata.ts'), ...process.argv.slice(2)], { stdio: 'inherit' }); process.exitCode = result.status ?? 1; ``` Run it: ```bash npm run generate ``` It reports the number of files changed: `Features/generatedMetadata.ts` and proxies beside `Writing.ts` and `Listing.ts` (including a model proxy). No `index.ts` barrels are added to the backend tree. Commit the metadata and proxies with your source, and never edit them by hand. Arc rejects metadata that no longer matches the classes when the server starts, so regenerate after every change to a command, read model, or validator. In CI, `npm run generate -- --check-metadata` fails when the committed module is stale. [Generate artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) covers what the module records. ## Run the development loop Use two terminals. The first regenerates the metadata whenever an artifact changes: ```bash npm run generate -- --watch ``` It prints `Watch ready`. The second runs the server with `tsx`, which compiles each file in memory and restarts when a file changes: ```bash npm run dev ``` The server prints nothing when it starts. It listens on `127.0.0.1:3000` until you press Ctrl+C; set `PORT` to use another port. When you change a command’s `handle()` signature, `tsx` may restart first and stop with `Stale generated artifact metadata`. The generator then rewrites the metadata, and `tsx` restarts again with it. Node.js type stripping cannot run this project on its own: it does not transform decorators, so `node main.ts` stops with `SyntaxError: Invalid or unexpected token`. Use `tsx` while you develop and `npm run build` for a compiled run. ## Check it with curl Write a note: ```bash curl -X POST http://127.0.0.1:3000/api/notes/writing/write-note \ -H 'content-type: application/json' \ -d '{"id":"3f1c2a4e-9b7d-4c1e-8a2f-5d6e7f809a1b","text":"Buy milk"}' ``` The answer is HTTP 200 with `"isSuccess":true` and the note’s ID as `response`. Now send an empty text: ```bash curl -X POST http://127.0.0.1:3000/api/notes/writing/write-note \ -H 'content-type: application/json' \ -d '{"id":"4a2d3b5f-0c8e-4d2f-9b3a-6e7f8091a2c3","text":""}' ``` The answer is HTTP 400, and `validationResults` holds the rule from your validator: ```json {"severity":3,"message":"A note needs text","members":["text"],"reason":"rule"} ``` Read the notes back: ```bash curl http://127.0.0.1:3000/api/notes/listing/all-notes ``` The answer’s `data` is `[{"id":"3f1c2a4e-9b7d-4c1e-8a2f-5d6e7f809a1b","text":"Buy milk"}]`. The store lives in memory, so a restart empties it. ## Build and start For a run without `tsx`, compile to `dist` and start the emitted JavaScript: ```bash npm run build npm start ``` `npm run build` regenerates the metadata before it compiles, so the build never ships stale metadata. Add `node_modules/` and `dist/` to `.gitignore`. The generated `*.proxy.ts` files remain beside the backend slices; include those files and the React `.tsx` components in your web tsconfig, not the backend one. See [Set up proxy generation](/arc/backend/typescript/proxy-generation/getting-started/) for the frontend configuration and a separate-output alternative. ## Choose a host `app.run()` starts Arc’s standalone Node host. To serve the same artifacts from a web framework, keep the builder lines in `main.ts` and replace `app.run()` with the framework’s adapter. Pack and install the adapter as you did the core, together with the framework itself, for example `npm install ../arc-packages/arc.express.tgz express@^5`: | Host | Package to pack | Guide | | --- | --- | --- | | Standalone Node host | none; it is part of `@cratis/arc.core` | [Arc.Core](/arc/backend/typescript/core/) | | Express 5 | `@cratis/arc.express` | [Express](/arc/backend/typescript/hosts/express/) | | Fastify 5 | `@cratis/arc.fastify` | [Fastify](/arc/backend/typescript/hosts/fastify/) | | Hono 4 | `@cratis/arc.hono` | [Hono](/arc/backend/typescript/hosts/hono/) | | Bun, Deno, Workers, or another Fetch API runtime | none; the `@cratis/arc.core/fetch` entry, which registers artifacts with `builder.add(...)` instead of discovery | [Fetch API runtimes](/arc/backend/typescript/hosts/fetch-runtimes/) | The [hosting overview](/arc/backend/typescript/overview/) compares them. ## Work inside the clone instead Inside a clone, the root `package.json` makes every folder under `Samples/` a Yarn workspace, and `workspace:^` links the local packages without packing. Create `Samples/MyArcApp` with the same files as above, but with this `package.json`: Samples/MyArcApp/package.json ```json { "name": "my-arc-app", "private": true, "type": "module", "scripts": { "generate": "node generate.mjs", "build": "yarn generate && tsc6", "start": "node dist/main.js", "dev": "tsx watch main.ts" }, "dependencies": { "@cratis/arc.core": "workspace:^", "@cratis/fundamentals": "7.22.0", "@opentelemetry/api": "^1.9.0" }, "devDependencies": { "@cratis/arc.proxygenerator": "workspace:^", "@types/node": "^22.0.0", "tsx": "^4.20.0", "typescript": "npm:@typescript/typescript6@^6.0.2" } } ``` Then install and run from the repository root: ```bash yarn install yarn build yarn workspace my-arc-app generate yarn workspace my-arc-app dev ``` `yarn install` adds your workspace to `yarn.lock`. Keep that change, and the application, out of any pull request to this repository. `yarn build` rebuilds the packages your application links to, so run it after you change Arc itself. ## If something goes wrong * `npm install` cannot find `@cratis/arc.core`, or reports `workspace:^`: see [Installing Arc outside the clone fails](/arc/backend/typescript/troubleshooting/#installing-arc-outside-the-clone-fails). * The server stops with `Unbound handle parameters`, `Missing parameter metadata`, or `Stale generated artifact metadata`: see [Unbound handle parameters](/arc/backend/typescript/troubleshooting/#build-fails-with-unbound-handle-parameters-or-missing-parameter-metadata) and [Stale generated metadata](/arc/backend/typescript/troubleshooting/#the-server-stops-with-stale-generated-artifact-metadata). * `corepack: command not found`: see [Corepack is missing](/arc/backend/typescript/troubleshooting/#corepack-is-missing). ## Next steps * [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/) continues with this project: it starts a local Chronicle kernel and records a first event from a command. * [Your first command](/arc/backend/typescript/getting-started/your-first-command/) explains concept validators, read models, and a spec, using the Tasks sample. * [Set up proxy generation](/arc/backend/typescript/proxy-generation/getting-started/) points the generator at a React frontend and installs the client packages the proxies import. * [Testing](/arc/backend/typescript/testing/) runs commands and queries through Arc’s pipelines in a spec, without a listener. It needs `@cratis/arc.testing`, packed like the core. * [Configuration](/arc/backend/typescript/configuration/) lists the settings `createBuilder()` reads from `appsettings.json` and `Cratis__...` environment variables. * [Vertical slices](/arc/backend/typescript/vertical-slices/) explains the folder layout this guide uses. # Explore the Library sample > Run the event-sourced Library sample against a local Chronicle kernel, then trace one feature from command to event, projection, and a live React view. What does an event-sourced Arc feature look like when it reaches a browser? The Library sample takes the authors and books from the [shared tutorial](/arc/tutorial/first-slice/) and makes the whole path visible: a command returns an event, Chronicle appends it and projects the read model, and an observable query updates the React view. Source preview Arc for TypeScript server packages and their Chronicle integration are experimental source-preview packages. Run this sample inside a clone of the repository, against your own development Chronicle kernel. Its fixed librarian identity is for local demonstration only; never expose this server publicly. ## Open your library With Node.js 22.19 or later, Yarn 4, and Docker installed, run from the repository root: ```sh yarn install docker compose -f Samples/Library/docker-compose.yml up -d yarn workspace @cratis/arc.sample.library dev ``` Visit . Register an author, select their name, and add a book. After Chronicle projects the event, the author count and list update without a refresh. The book appears on the selected author’s shelf. Stop the dev process with Ctrl+C, then stop this sample’s kernel with `docker compose -f Samples/Library/docker-compose.yml down --volumes`. That removes the container and the volumes of its bundled MongoDB, and with them the development data. Vite sends `/api` and `/.cratis` requests to Express on loopback. If the ports are occupied, free ports 3000, 5173, and 35000 before starting. ## Trace one feature Start with [`Registration.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Registration/Registration.ts). `RegisterAuthor.handle()` returns `new AuthorRegistered(this.name)`; Arc appends it to the event source marked by `@key()`. `UniqueAuthorName` defines a Chronicle append-time constraint across author streams, while the concept validator beside [`AuthorName`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/AuthorName.ts) checks the name’s shape. `@roles('Librarian')` requires an authenticated librarian. The local Express host supplies a fixed, trusted demonstration principal, **not** an authentication mechanism. Next read [`Listing.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Listing/Listing.ts): `@fromEvent(AuthorRegistered)` builds the projected read model, `allAuthors` watches Chronicle changes, and `authorsPage` loads a snapshot. Arc handles paging for that small list. In [`AuthorCatalog.tsx`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Listing/AuthorCatalog.tsx), `DataPage` receives the generated `AllAuthors` observable query and provides paging, sorting, and selection. `AuthorsPage` is a separate snapshot-query example; it does not drive this page. The book slices sit under `Features/Books/`. See [Vertical slices](/arc/backend/typescript/vertical-slices/) for the TypeScript layout alongside the C# version. The source generator produces both server metadata and browser proxies. When you change a command or query, regenerate rather than editing `generatedMetadata.ts` or the co-located `*.proxy.ts` files by hand. The [`sample README`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/README.md) explains configuration and verification. ## What the kernel does `appsettings.json` configures `Cratis.Chronicle` with the `Library` event store and a local development connection string. The compose image bundles MongoDB as Chronicle’s event store and read-model sink; the application never writes to MongoDB directly. A successful append may precede the projected query update. The sample loads the small catalog before Arc pages it, so do not use that query for an unbounded production list. `bash Samples/Library/run-integration.sh` starts its own Chronicle container and tests the generated proxy HTTP round trip, including uniqueness and an observable update. It does not exercise the browser UI. You have now traced a typed React hook through Express into Chronicle and back through a projected observable query. To build the same loop in a project of your own, [create an application](/arc/backend/typescript/getting-started/create-an-application/) and then [add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/) to it. [Your first command](/arc/backend/typescript/getting-started/your-first-command/) walks the smaller, infrastructure-free Tasks sample if you want to isolate the Arc decorators. # Your first command > Walk through the Tasks sample's concepts, command, validators, read model, entry point, and spec, and see what Arc does with each decorator. The [Get started](/arc/backend/typescript/getting-started/) page ran the Tasks sample from the outside: a command stored a task, a rule refused an empty title, and a query served the result. None of that needed a route, a body parser, or an error mapper. This page opens the sample up so you can see which few lines produced each behavior. You follow one task from its value types, through the command that registers it and the rules that guard it, to the read model that serves it and the spec that proves it. Every snippet is the sample’s real code; the file links take you to the full source. ## Name the values first A task ID should not look like every other string. The sample wraps each domain value in a Fundamentals concept: Features/Tasks/TaskId.ts ```typescript import { ConceptAs, Guid } from '@cratis/fundamentals'; export class TaskId extends ConceptAs { static readonly valueType = Guid; static create(): TaskId { return new TaskId(Guid.create()); } } ``` Features/Tasks/TaskTitle.ts ```typescript import { ConceptAs } from '@cratis/fundamentals'; export class TaskTitle extends ConceptAs { static readonly valueType = String; } ``` Why bother? A handler that takes `(id: string, title: string)` accepts the arguments in either order and compiles. A handler that takes a `TaskId` and a `TaskTitle` does not. TypeScript erases the generic argument of `ConceptAs` at runtime, so `static readonly valueType` tells Arc what the wire value is. On the wire, a `TaskId` is a UUID string; inside your handler it is a `TaskId`. Arc converts in both directions, and a string that is not a UUID is rejected as a malformed request before your code sees it. [Concepts](/arc/backend/typescript/concepts/) covers the supported value types. ## Declare the command A command is a class. Its decorated fields are the input, and `handle()` is the work: Features/Tasks/Registration/Registration.ts ```typescript import { field } from '@cratis/fundamentals'; import { command, CommandValidator, validator } from '@cratis/arc.core'; import { TaskId } from '../TaskId.js'; import { TaskTitle } from '../TaskTitle.js'; import { Tasks } from '../Tasks.js'; @command() export class RegisterTask { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; handle(tasks: Tasks): TaskId { tasks.register(this.id, this.title); return this.id; } } @validator(RegisterTask) export class RegisterTaskValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.title).notEmpty().withMessage('A title is required'); this.ruleFor(command => command.title).maxLength(100).withMessage('A title can have at most 100 characters'); } } ``` Here is what happens that you do not see. Arc reads the JSON body, checks it against the schema derived from the `@field` declarations, and turns the strings into a `TaskId` and a `TaskTitle` on a fresh `RegisterTask` instance. It opens a service scope, resolves `Tasks`, and calls `handle()`. The returned `TaskId` becomes the result’s `response`, encoded back to a string. Generated metadata lists the `Tasks` service for `handle()`: TypeScript does not keep parameter types at runtime. Without generated metadata, mark the method with `@inject(Tasks)`. [Dependency injection](/arc/backend/typescript/dependency-injection/) explains the lifetimes. The route comes from the folder: the sample discovers artifacts under `Features/`, so `Tasks/Registration/Registration.ts` becomes `/api/tasks/registration/register-task`. [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) shows how to keep a route stable when you move a file. ## Guard the input with rules A title must not be blank. The `RegisterTaskValidator` above lives in the **same** `Registration.ts` as its command. That is the [vertical slice convention](/arc/backend/typescript/vertical-slices/), not a requirement that Arc puts on TypeScript files. A rule that belongs to the value itself, wherever it appears, goes on the concept: Features/Tasks/TaskTitleValidator.ts ```typescript import { ConceptValidator, validator } from '@cratis/arc.core'; import { TaskTitle } from './TaskTitle.js'; @validator(TaskTitle) export class TaskTitleValidator extends ConceptValidator { constructor() { super(); this.ruleFor(title => title.value).must(value => !value.startsWith('!')) .withMessage('A title cannot begin with an exclamation mark'); } } ``` Arc runs both before `handle()`, and on the `/validate` route. Send `"title":"!Loud"` to the validation route and the answer is 400 with `A title cannot begin with an exclamation mark` for the member `title`. [Command validation](/arc/backend/typescript/commands/command-validation/) lists the rules you can use. ## Serve the data with a read model Queries live on a read model as static methods: Features/Tasks/Listing/Listing.ts ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel, service } from '@cratis/arc.core'; import type { BehaviorSubject } from 'rxjs'; import { TaskId } from '../TaskId.js'; import { TaskTitle } from '../TaskTitle.js'; import { Tasks } from '../Tasks.js'; @readModel() export class TaskItem { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; @query(service(Tasks)) static allTasks(tasks: Tasks): TaskItem[] { return tasks.all(); } @query() static taskById(id: TaskId, tasks: Tasks): TaskItem | undefined { return tasks.byId(id); } @query() static observeAllTasks(tasks: Tasks): BehaviorSubject { return tasks.observeAll(); } } ``` Generated metadata binds `id` from the query string and `Tasks` from the service scope. Without generated metadata, list `argument('id', TaskId)` and `service(Tasks)` in parameter order on `@query(...)`. `observeAllTasks` returns a live source, so the same route answers a snapshot on GET and streams updates over server-sent events or WebSockets. The sample’s in-memory [`Tasks`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Tasks.ts) service stands in for real storage. [Model-bound queries](/arc/backend/typescript/queries/model-bound/) and [observable queries](/arc/backend/typescript/queries/observable-queries/) go further. ## Wire it together The [Tasks entry point](/arc/backend/typescript/getting-started/#see-what-started-the-server) registers the service, installs generated metadata, discovers the decorated classes, and starts the host. Get started shows the complete `main.ts`; use it to see how the sample starts. `discover()` imports every exported class under `Features/` and picks up commands, read models, and validators by their decorators. `build()` checks the whole graph (every injected service registered, no lifetime mismatches, no misplaced decorators) before a listener opens. The sample binds `Cratis:Arc:Development` to enable fixture providers. Its Yarn start script sets `DOTNET_ENVIRONMENT=Development` for local anonymous discovery; the flag alone does not bypass the [discovery access policy](/arc/backend/typescript/introspection/#production-access). Exception details instead follow `Cratis:Arc:ExposeExceptionDetails`, which defaults on only in a Development environment; keep it off on public hosts. The discovery folder must not contain the entry point `discover()` refuses a folder that contains the module currently calling it. Keep your artifacts in a dedicated folder such as `Features/`, as the sample does. ## Prove it with a spec The sample tests the command through the real pipeline without starting a server: Features/Tasks/Registration/for_RegisterTask/given/a_task_registration.ts ```typescript import { CommandScenario } from '@cratis/arc.testing'; import { Tasks } from '../../../Tasks.js'; import { RegisterTask, RegisterTaskValidator } from '../../Registration.js'; import { metadata } from '../../../../generatedMetadata.js'; export class a_task_registration { tasks = new Tasks(); scenario = CommandScenario.for(RegisterTask, RegisterTaskValidator); constructor() { this.scenario.extend(builder => builder.useGeneratedMetadata(metadata)); this.scenario.services.addSingleton(Tasks, this.tasks); } } ``` The context builds the same kind of application `main.ts` builds, minus the listener: the generated metadata, the command and its validator, and a `Tasks` instance the spec can inspect afterward. Features/Tasks/Registration/for_RegisterTask/when_validating/with_an_empty_title.ts ```typescript import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; import { TaskId } from '../../../TaskId.js'; import { TaskTitle } from '../../../TaskTitle.js'; import { a_task_registration } from '../given/a_task_registration.js'; describe('when validating a task with an empty title', given(a_task_registration, context => { let result: ScenarioCommandResult; beforeEach(async () => { result = await context.scenario.validate({ id: TaskId.create(), title: new TaskTitle('') }); }); afterAll(async () => { await context.scenario.dispose(); }); it('should report the authored rule for title', () => { result.shouldHaveValidationErrors().shouldHaveValidationErrorForMember('title'); }); it('should not invoke the handler', () => { context.tasks.all().should.have.lengthOf(0); }); })); ``` `validate()` sends the values through the same authorization and validation the `/validate` route runs, then stops. The first assertion proves that an authored rule failed for the `title` member; the second proves that `handle()` never stored anything. Together they pin the behavior you saw with `curl`: the rule, the field, and the untouched store. Assert on the member, not a word in the message `shouldHaveValidationErrorFor(text)` matches a *message fragment*. `shouldHaveValidationErrorFor('title')` passes only because “A title is required” happens to contain the word, and it keeps passing if a different rule with “title” in its message fails instead. Use `shouldHaveValidationErrorForMember('title')` to assert the field, and pass the full message to `shouldHaveValidationErrorFor` when the wording matters. Run the sample’s specs from the repository root: ```bash yarn vitest run Samples/Tasks ``` Vitest runs the sample’s command and query specs, and every test passes. [Testing](/arc/backend/typescript/testing/) covers commands, queries, and observable queries. ## Recap A concept names a value, a command class carries the input and the work, validators hold the rules, a read model serves the data, and the builder wires them by convention. Arc owns the HTTP, the binding, the rule order, and the result envelope. ## Next step The server works; now give it a user interface. [Continue in the browser](/arc/backend/typescript/getting-started/continue-in-the-browser/) generates a typed client from these same classes and calls the server from a small React page. When you want to go deeper: * [Commands](/arc/backend/typescript/commands/) for command context, outcomes, and operations. * [Queries](/arc/backend/typescript/queries/) for arguments, paging, and live queries. * [Coming from Express and NestJS](/arc/backend/typescript/coming-from-express-and-nestjs/) if you want to compare this with the code you write today. # Host adapters > Mount Arc in Express, Fastify, Hono, or a Fetch API host; know which runtime features each host supports. Your team already runs a web framework, with its middleware, health checks, and deployment story. You do not want a second server for Arc. A host adapter mounts the Arc application into the framework you have and leaves every route Arc does not own to that framework. Source preview The adapters are not published to npm. Pack the adapter you need from a built clone of this repository and install the tarball in your project, or put your application under `Samples/` in the clone and reference the adapter with the `workspace:^` protocol. [Create an application](/arc/backend/typescript/getting-started/create-an-application/) shows both paths, and [Packages](/arc/backend/typescript/reference/packages/) lists the package names. ## Before you start * An ES module package (`"type": "module"`). Applications using the adapters and Core need Node.js 22 or later; building the workspace needs 22.19 or later, and Node.js 24 LTS is recommended. * A built Arc application. Keep it in its own module so every host imports the same instance: arc.ts ```typescript import { ArcApplication } from '@cratis/arc.core'; import { Tasks } from './Features/Tasks/Tasks.js'; import { metadata } from './Features/generatedMetadata.js'; const builder = ArcApplication.createBuilder(); builder.useGeneratedMetadata(metadata); builder.services.addSingleton(Tasks); await builder.discover(new URL('./Features/', import.meta.url)); export const arc = await builder.build(); ``` This is the [Tasks sample’s bootstrap](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/main.ts) without `app.run()`, because the framework owns the listener. `metadata` is the module that `arc-proxygenerator --metadata` writes; see [Generate artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/). Register your own services the way the sample registers `Tasks`. `build()` checks the artifacts and options and throws when something is wrong, so a mistake shows up when the process starts rather than on the first request. | Package | Exports | Framework peer range | | --- | --- | --- | | `@cratis/arc.express` | `cratisArc(arc)` middleware and `.injectWebSocket(listener)` | `express` `^5.0.0` | | `@cratis/arc.fastify` | `app.register(cratisArc, { arc })` (WebSockets on by default) | `fastify` `^5.0.0` | | `@cratis/arc.hono` | `app.use(cratisArc(arc))`; Node: `serveCratisArc` | `hono` `^4.0.0`; optional `@hono/node-server` `^1.19.11` for Node hosting | | `@cratis/arc.core/hosting` | `attachNodeWebSockets` and adapter hosting primitives | None | Each adapter accepts a built `ArcApplication` or low-level `ArcServer`. The `@cratis/arc.core/hosting` subpath also exposes integration-only command helpers. Applications must not call these helpers; `inlineCommitClientResponse`, for example, lets a trusted integration mark a command whose handler commits inline. ## Pick your framework * [Express](/arc/backend/typescript/hosts/express/): one middleware, mounted before body parsers. * [Fastify](/arc/backend/typescript/hosts/fastify/): an encapsulated plugin with its own raw-body parser. * [Hono](/arc/backend/typescript/hosts/hono/): middleware on a Fetch API framework, with raw-path checks on Node. * [Fetch API runtimes](/arc/backend/typescript/hosts/fetch-runtimes/): explicitly registered artifacts over `app.fetch` for a checked Next.js Node.js production-server route handler and bounded Bun/Deno checks. The package is an unpublished source preview; read the verification boundary before deployment. Express needs a listener attach step for WebSockets; Fastify’s plugin and Hono’s Node helper attach them in the setup call, described in [WebSockets](/arc/backend/typescript/hosts/websockets/). To use a principal your framework already verified, see [Native principal](/arc/backend/typescript/hosts/native-principal/). ## What every adapter shares The adapters add no Arc behavior of their own. Each one: * dispatches only requests whose path exactly matches an Arc route, on a fixed internal origin, so a crafted `Host` header cannot select a different operation (Express and Fastify compare the raw path; Hono checks the raw request-target only on `@hono/node-server`); * hands Arc the unparsed body and lets Arc enforce `hosting.maxBodyBytes`; * passes a cancellation signal that becomes `context.signal`; pass it to anything that accepts one, such as `fetch` or a database driver; * serves the same routes: commands, validation routes, queries, observable queries, and the `/.cratis` and `/openapi.json` endpoints listed in the [HTTP contract reference](/arc/backend/typescript/reference/http-contract/). ## Where adapters differ | Area | Express | Fastify | Hono | | --- | --- | --- | --- | | Registration | One middleware, before body parsers | Encapsulated plugin; routes exist once the app is ready | One middleware | | Request bodies | Raw request stream | Raw buffer from a scoped catch-all parser | The Fetch API request body | | Body size | Arc’s `hosting.maxBodyBytes` | Fastify’s `bodyLimit` first, then `hosting.maxBodyBytes` | Arc’s `hosting.maxBodyBytes` | | Unknown path | Falls through to your routes; Express’s own 404 carries no Arc correlation header | Fastify’s 404 | Falls through to your routes | | `context.signal` aborts | When the client disconnects before the response finishes | When the client disconnects before the response finishes | When the signal of the request Hono received aborts; depends on the server running Hono | | Application type | `Express` | `FastifyInstance` | `Hono` for any `Env` type | ## Ownership and shutdown The framework owns its listener. Mounting an application does not make the adapter own shutdown: call `shutdownArcHost(arc.server, () => framework.close())` from `@cratis/arc.core/hosting` to start participant shutdown before closing a listener with live observables. Express also provides `middleware.shutdown(listener)`. If your host supplied Arc’s registry, pass it explicitly as the third argument to `shutdownArcHost` (or the second argument to Express `shutdown`); it is never disposed implicitly. Without participants the helper closes the framework first, then disposes Arc, as before. `framework.close()` or `middleware.close(listener)` alone remains listener-only. ## Related * [Hosting overview](/arc/backend/typescript/overview/) * [Arc.Core and the standalone host](/arc/backend/typescript/core/) for hosting without a framework * [Calling commands from code](/arc/backend/typescript/commands/calling-commands-from-code/) # Express > Mount an Arc application in Express 5 before body parsers, and know how the adapter matches paths and reports errors. `@cratis/arc.express` adds one middleware to an Express 5 application. Arc answers its own routes; everything else goes to your routes and middleware. ## Mount the application server.ts ```typescript import express from 'express'; import { cratisArc } from '@cratis/arc.express'; import { arc } from './arc.js'; const app = express(); const middleware = cratisArc(arc); app.use(middleware); app.use(express.json()); app.get('/health', (_request, response) => { response.send('ok'); }); const listener = app.listen(3000, '127.0.0.1'); middleware.injectWebSocket(listener); process.once('SIGTERM', () => { void middleware.shutdown(listener); }); ``` `arc` is the built application from [Host adapters](/arc/backend/typescript/hosts/#before-you-start). `middleware.shutdown(listener)` coordinates participant stop and drain with transport closure. If you supplied your own `ServiceRegistry`, pass it explicitly as `middleware.shutdown(listener, registry)`; Arc never implicitly disposes a borrowed registry. With the Tasks sample’s artifacts, `POST /api/tasks/registration/register-task` now reaches Arc, and `GET /health` reaches your route. Mount Arc before body parsers Arc reads the raw request body itself. If `express.json()` or another body parser runs first, it consumes the body, and Arc answers every command with 400 `malformedRequest`. Install `cratisArc(arc)` before you add body parsers, as in the example. ## How requests are matched The middleware compares the raw request path, before any normalization, with Arc’s registered routes. Only an exact match is handled by Arc. Every other request goes to `next()`, including spellings such as `//api/echo`, `/x/../api/echo`, or `/api/%2e%2e/api/echo` that would only match after normalization. The adapter builds the Fetch API request on a fixed internal origin and never uses the `Host` header for routing. An unexpected error inside the adapter is passed to Express with `next(error)`. An unknown path gets Express’s own 404, which carries no Arc correlation header. `context.signal` aborts when the client disconnects before the response finishes. ## Pass a verified principal `cratisArc(arc, native)` accepts a second argument: a callback returning trusted native context for each request. Use it with `nativePrincipal: true` to pass a user your Express session or JWT middleware already verified. See [Native principal](/arc/backend/typescript/hosts/native-principal/). ## Observable queries over WebSockets Express HTTP middleware does not run on Node `upgrade` requests. Attach WebSockets to the listener with `middleware.injectWebSocket(listener, native?)`; the middleware cannot see upgrades. Call `middleware.shutdown(listener)` to close the listener and Arc in the required order. `middleware.close(listener)` remains listener-only; if participants are registered, closing the listener first can start scope disposal before they stop. See [WebSockets](/arc/backend/typescript/hosts/websockets/#express). ## Related * [Host adapters](/arc/backend/typescript/hosts/) * [Fastify](/arc/backend/typescript/hosts/fastify/) and [Hono](/arc/backend/typescript/hosts/hono/) # Fastify > Mount an Arc application in Fastify 5 as an encapsulated plugin, and account for Fastify's body limit and lazy plugin loading. `@cratis/arc.fastify` registers an encapsulated plugin that owns Arc’s routes in a Fastify 5 application. Your own parsers, hooks, and routes stay as they are. ## Mount the application server.ts ```typescript import Fastify from 'fastify'; import cratisArc from '@cratis/arc.fastify'; import { shutdownArcHost } from '@cratis/arc.core/hosting'; import { arc } from './arc.js'; const app = Fastify(); await app.register(cratisArc, { arc }); app.get('/health', async () => 'ok'); await app.listen({ port: 3000, host: '127.0.0.1' }); process.once('SIGTERM', () => { void shutdownArcHost(arc.server, () => app.close()); }); ``` `arc` is the built application from [Host adapters](/arc/backend/typescript/hosts/#before-you-start). `shutdownArcHost` starts participant shutdown before closing the listener; host-first closure can dispose live subscription scopes too early. If the registry belongs to your host, pass it as the third argument to explicitly transfer shutdown ownership. See [Shutdown participants](/arc/backend/typescript/dependency-injection/#scopes-and-disposal). Fastify loads plugins lazily, so Arc’s routes exist once the application is ready: after `listen`, `ready`, or the first `inject`. Do not register your own routes on Arc’s paths; Fastify rejects the duplicate when it loads the plugin. ## Bodies and methods Inside the plugin, one catch-all content type parser hands every body to Arc as a raw buffer, whatever its content type, including vendor types such as `application/vnd.acme+json`. Arc decodes it as strict UTF-8 JSON, so invalid UTF-8 answers 400 `malformedRequest`. Your application’s parsers are not changed. Command and query routes are registered for GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD, and `QUERY`; the root `/.cratis` metadata, identity, and discovery routes are registered for the standard methods without `QUERY`. Arc answers methods it receives but does not accept with 405 and an `Allow` header. Other methods are left to Fastify’s routing, often 404. The handler dispatches only when the raw request path equals the route Fastify matched, on a fixed internal origin, so a crafted `Host` header cannot select a different operation. `context.signal` aborts when the client disconnects before the response finishes. Fastify’s body limit applies first Fastify enforces its own `bodyLimit`, 1 MiB by default, before Arc reads the body. A larger body gets Fastify’s 413 response, not an Arc result. To accept larger bodies, raise both `bodyLimit` and Arc’s `hosting.maxBodyBytes`. ## Pass a verified principal `app.register(cratisArc, { arc, native })` accepts a callback returning trusted native context for each request. See [Native principal](/arc/backend/typescript/hosts/native-principal/). ## Observable queries over WebSockets `app.register(cratisArc, { arc })` registers HTTP and observable upgrades together (`webSockets` defaults to `true`). Set `webSockets: false` to disable upgrades. A shared `@fastify/websocket` can be registered before or after Arc; both orders are covered by real upgrade checks. The plugin also supports a Fastify registration prefix, including one inherited from a parent plugin. See [WebSockets](/arc/backend/typescript/hosts/websockets/#fastify). ## Related * [Host adapters](/arc/backend/typescript/hosts/) * [Express](/arc/backend/typescript/hosts/express/) and [Hono](/arc/backend/typescript/hosts/hono/) # Fetch API runtimes > Serve Arc commands, queries, and SSE from a Next.js Node.js route handler or another Node-compatible host without an Arc-owned listener. You can handle Arc requests without an Arc-owned listener. Import the **source-preview** `@cratis/arc.core/fetch` entry, register artifacts explicitly, and hand the resulting `app.fetch` to your host. The package is not published to npm; install a tarball packed from a built clone, as [Create an application](/arc/backend/typescript/getting-started/create-an-application/) shows, or use it inside the clone’s workspace. **Arc targets Node-compatible servers:** Chronicle uses gRPC, MongoDB uses TCP, and live queries rely on long-lived SSE/WebSocket connections. Next.js App Router on its **Node.js** runtime is verified locally. Bun has a smoke check, not a supported deployment guarantee. Cloudflare Workers and Next.js Edge are **not supported** hosts for the full framework or these integrations. ## Build the application once The Fetch entry has the same command/query pipelines as Node, but does not load configuration files or discover files. Use `builder.add(...)` or `builder.useGeneratedMetadata(metadata)` followed by `add(...)`. With no generated metadata, bind method parameters explicitly using the [model-bound APIs](/arc/backend/typescript/commands/model-bound/). Ping.ts ```typescript import { command } from '@cratis/arc.core/fetch'; /** A minimal command served by the Fetch host. */ @command() export class Ping { handle(): string { return 'pong'; } } ``` Status.ts ```typescript import { query, readModel } from '@cratis/arc.core/fetch'; /** A read model with one snapshot query. */ @readModel() export class Status { @query() static all(): string[] { return ['ready']; } } ``` arc.ts ```typescript import { ArcApplication } from '@cratis/arc.core/fetch'; import { Ping } from './Ping.js'; import { Status } from './Status.js'; const builder = ArcApplication.createBuilder(); builder.add(Ping, Status); declare global { var __cratisArcFetchApp: ReturnType | undefined; } export const app = await (globalThis.__cratisArcFetchApp ??= builder.build()); ``` `POST /api/ping` with a JSON `{}` body returns `response: "pong"`; `GET /api/all` returns `data: ["ready"]`. The [Fetch scenario](https://github.com/Cratis/Arc.TypeScript/blob/main/scripts/fetch-runtime-scenario.mjs) exercises commands, validation, authorization, GET and `QUERY`, tenant and correlation headers, observable snapshots, direct and hub SSE, server-side direct SSE subscription release after client abort, and fall-through. Use your host’s build tool to compile decorators and bundle dependencies. A neutral esbuild bundle externalizes **only** `node:async_hooks` (for `AsyncLocalStorage`); it imports no `node:fs`, `node:crypto`, `ws`, or other Node modules from the Fetch entry. The HTTP checks use built workspace `dist`, not a published tarball. ### Bun server.ts ```typescript import { app } from './arc.js'; Bun.serve({ port: 3000, fetch: (request: Request) => app.fetch(request) }); ``` Stop the Bun server before calling `await app.dispose()` in your shutdown hook. `yarn check:fetch:bun` runs the shared scenario through `Bun.serve` on Bun 1.3.10. This is an optional smoke check only: Chronicle gRPC, MongoDB, production deployment, and lifecycle under load are unverified. ### Deno server.ts ```typescript import { app } from './arc.js'; Deno.serve({ hostname: '127.0.0.1', port: 3000 }, (request: Request) => app.fetch(request)); ``` This hosting call requires Deno network permission. Deno 2.9.7 previously passed the smaller command, query, direct and hub SSE scenario; the expanded scenario and `Deno.serve` listener have not been rerun there. This is not a full Arc deployment target verification. ### Next.js App Router on Node.js Pin the Node.js runtime. Use two catch-all route handlers: `app/api/[...arc]/route.ts` serves commands, queries, and direct SSE; `app/.cratis/[...arc]/route.ts` serves the SSE hub and its control requests. Both handlers import the same application. Next.js may bundle separate route entries independently, so cache the build promise on the process global rather than building an app for every request or route. The `arc.ts` module above caches its build promise for this Node.js process; separate instances still need their own applications and their own stores. app/api/\[...arc]/route.ts ```typescript import { app } from '../../../arc.js'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const GET = (request: Request): Promise => app.fetch(request); export const POST = (request: Request): Promise => app.fetch(request); ``` app/.cratis/\[...arc]/route.ts ```typescript import { app } from '../../../arc.js'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const GET = (request: Request): Promise => app.fetch(request); export const POST = (request: Request): Promise => app.fetch(request); ``` For model-bound artifacts on Next.js 15.5.14, disable server minification: it changes class names used by Arc’s convention-based routes (`Submit` became `tb` in the production check). Keep your other Next configuration alongside this setting: next.config.mjs ```javascript export default { experimental: { serverMinification: false } }; ``` `yarn check:fetch:next` builds and starts these routes with `next build && next start` on Node.js 26.8.1. It checks GET, POST commands, validation, authorization, first direct and hub SSE frames, direct SSE subscription release after client abort, tenant and correlation headers. The unmatched route returns Next.js’s own 404, not an Arc fall-through response. Next.js rejects the nonstandard HTTP `QUERY` method with **400 before the route handler**; use GET instead. This local production-server check does not verify deployed serverless streaming persistence, proxy buffering, WebSocket upgrades, or Chronicle/MongoDB integrations. Next.js Edge is not supported: `node:async_hooks` and the full framework’s gRPC/TCP and long-lived streams are outside the Edge target. ## Boundaries and verification The Fetch entry cannot perform file discovery or load configuration files, start a Node listener, serve static files, or upgrade WebSockets. Calling `discover()` on its builder fails rather than importing files. Use `@cratis/arc.core` unchanged on Node for those features. Both entries share the core pipelines; Chronicle and storage integrations require their own host-specific verification. The application does not own the host listener: close that listener before `await app.dispose()`. A Fetch host is responsible for trusted principal metadata, ingress limits, and canceling long-lived streams when clients disconnect. `yarn check:fetch` bundles the compiled **package export** on esbuild’s neutral platform, allows only `node:async_hooks`, and runs the shared scenario inside a Node VM without `process` or `Buffer`. `yarn check:fetch:deno` runs the same scenario in Deno. `yarn check:fetch:bun` and `yarn check:fetch:next` bundle the same built entry and send real HTTP requests through `Bun.serve` or Next.js App Router respectively. The Next.js `QUERY` check pins the host’s rejection instead of claiming Arc dispatch; all other scenario assertions are shared. Next.js is a root development dependency for the production check; set `NEXT_BIN` to override it. Install optional Bun outside the workspace and set `BUN_BIN` and `ARC_RUNTIME_SCRATCH` as needed. Missing executables return exit code 2 (not run); broken installations and failed checks return exit code 1. The runtime scripts use `/.ai-work/scratch/runtimes` inside the repository checkout for temporary host files and remove generated files afterward. If you have an older Bun installation under the previous `../../scratch/runtimes` location, reinstall it in the new scratch location or set `ARC_RUNTIME_SCRATCH` to that directory. The Fetch entry’s `node:async_hooks` requirement is satisfied by Node.js, Bun, and Deno. It does not bring in Node filesystem, Node crypto, or Node streams; Fetch globals supply crypto and streams. Cloudflare Workers (even with `nodejs_compat`) are **not supported** for the framework: Chronicle gRPC, MongoDB TCP, and long-lived live-query connections are not verified or promised there. These local checks do not establish full compatibility for every optional integration. # Hono > Mount an Arc application in Hono 4 with your own Env type, run it on Node with @hono/node-server, and know what other Hono runtimes cannot attest. `@cratis/arc.hono` adds middleware to a Hono 4 application. Hono speaks the Fetch API, so the adapter copies the request onto a fixed internal origin, hands it to Arc, and returns Arc’s response. ## Mount the application server.ts ```typescript import { Hono } from 'hono'; import { cratisArc, serveCratisArc } from '@cratis/arc.hono'; import { arc } from './arc.js'; const app = new Hono<{ Variables: { startedAt: number } }>(); app.use('*', async (context, next) => { context.set('startedAt', Date.now()); await next(); }); app.use(cratisArc(arc)); app.get('/health', context => context.text(`ok since ${context.get('startedAt')}`)); const hosted = await serveCratisArc(app, arc, { port: 3000, hostname: '127.0.0.1' }); process.once('SIGTERM', () => { void hosted.dispose().then(() => arc.dispose()); }); ``` `arc` is the built application from [Host adapters](/arc/backend/typescript/hosts/#before-you-start). `cratisArc(arc)` returns HTTP/SSE middleware that continues to your routes for foreign paths; mount it before adding your routes. Use `app.use('/v1/*', cratisArc(arc))` to expose Arc under a prefix. `serveCratisArc` starts a Node listener with WebSocket upgrades and returns its listener and async disposer; it does not dispose `arc`. If you own the Node listener, call `const disposeSockets = cratisArc(arc).injectWebSocket(listener)` instead, then dispose the sockets before closing the listener. ## Run on Node To run on Node.js, add `@hono/node-server`, an optional peer dependency. On that server, the adapter also checks the raw request-target before dispatch, so URL normalization and absolute-form paths cannot promote a foreign path into an Arc endpoint. Other Hono runtimes Runtimes other than `@hono/node-server` do not expose the raw request-target spelling, so they cannot attest the same raw-path defense without host-specific validation. `context.signal` follows the signal of the request Hono received, which depends on that runtime. ## Pass a verified principal `cratisArc(arc, native)` accepts a callback returning trusted native context. On Node, `secure` comes from the TLS socket; a proxy’s `secure` and `authority`, and a host-verified `principal`, still require a trusted callback. See [Native principal](/arc/backend/typescript/hosts/native-principal/). ## Observable queries over WebSockets `serveCratisArc(app, arc, { port, native? })` installs observable WebSockets on the Node listener. Arc core imports Node modules: Hono runtimes other than Node have not been verified, even for HTTP/SSE, and need their own validated host and WebSocket bridge. The pinned `@hono/node-server` 1.x does not export the Hono WebSocket upgrade helper, so this Node adapter still uses `@hono/node-ws` internally. See [WebSockets](/arc/backend/typescript/hosts/websockets/#hono). ## Related * [Host adapters](/arc/backend/typescript/hosts/) * [Express](/arc/backend/typescript/hosts/express/) and [Fastify](/arc/backend/typescript/hosts/fastify/) # Native principal > Pass a principal your web framework already verified into Arc through a trusted adapter callback, instead of running Arc authentication handlers. Your Express session middleware, Fastify JWT plugin, or Hono auth middleware already knows who the caller is. By default Arc ignores that and runs its own [authentication handlers](/arc/backend/typescript/core/authentication/). When you want Arc to use the host’s verified user instead, switch on the native principal mode and hand the principal over explicitly. ## Turn it on Set `nativePrincipal: true` in the options, and pass a callback as the second argument to `cratisArc`. The callback runs for every Arc request and returns a `NativeRequestContext`: server.ts ```typescript import express from 'express'; import { ArcApplication, type Principal } from '@cratis/arc.core'; import { cratisArc } from '@cratis/arc.express'; // Your own session middleware sets request.user after verifying the session. type SessionRequest = express.Request & { user?: { id: string; roles: string[] } }; const builder = ArcApplication.createBuilder({ nativePrincipal: true }); await builder.discover(new URL('./Features/', import.meta.url)); const arc = await builder.build(); const app = express(); // app.use(yourVerifiedSessionMiddleware); app.use(cratisArc(arc, request => { const user = (request as SessionRequest).user; const principal: Principal | undefined = user ? { ...user, isAuthenticated: true } : undefined; return { principal }; })); ``` The session middleware in this example is yours. Arc does not implement or validate session cookies: host sessions can use `HttpOnly`, `Secure`, and `SameSite` cookies according to your framework’s own middleware. ## The native context | Property | Meaning | | --- | --- | | `principal` | The host-verified caller; used only when `nativePrincipal` is on | | `secure` | Whether the connection is TLS; Express and Fastify take it from the Node TLS socket unless you override it | | `authority` | A host name known from trusted configuration, not the request `Host` header; used by the `subdomain` tenant source | | `remoteAddress` | The caller’s address, for anonymous per-caller observable limits | The callback can be `async`. Adapters invoke it inside Arc’s error boundary, so a thrown error becomes a redacted 500 with a correlation ID and a logger call. Only return verified values Never build native context from browser-supplied properties, cookies, `X-Forwarded-*` headers, or the request URL. The `.cratis-identity` display cookie cannot authenticate a caller. ## Rules * `nativePrincipal: true` cannot be combined with Arc authentication handlers; the server refuses to start with both. * For an operation that requires a [named scheme](/arc/backend/typescript/core/authorization/#select-an-authentication-scheme), the callback must supply a principal carrying that `scheme`. * WebSocket upgrades take their own callback; see [WebSockets](/arc/backend/typescript/hosts/websockets/). * The standalone Node host takes the same callback as its `native` option; see [Arc.Core](/arc/backend/typescript/core/#security-defaults). * `server.handle(request, native)` accepts the same context, or an async callback returning it, for Fetch API hosts. This is a privileged, server-side seam. ## Related * [Authentication](/arc/backend/typescript/core/authentication/) * [Host adapters](/arc/backend/typescript/hosts/) # WebSockets > Mount WebSocket upgrades for observable queries in Express, Fastify, and Hono, authenticate them, and configure Origin checks and proxy trust. Observable queries stream over server-sent events on the ordinary HTTP route. Fastify’s plugin and Hono’s Node serving helper also attach WebSocket upgrades in their setup call. Express needs a listener attach step because Express middleware does not see Node upgrades. The standalone Node host handles upgrades itself; see [Arc.Core](/arc/backend/typescript/core/#bring-your-own-node-server) for your own Node server. `arc` below is the built `ArcApplication`. ## Express server.ts ```typescript import express from 'express'; import { cratisArc } from '@cratis/arc.express'; import { arc } from './arc.js'; const app = express(); const middleware = cratisArc(arc); app.use(middleware); const listener = app.listen(3000, '127.0.0.1'); const closeSockets = middleware.injectWebSocket(listener); ``` Call `middleware.injectWebSocket(listener, native?)` on the listener returned by `app.listen()`, and `await closeSockets()` at shutdown. Express HTTP middleware does **not** run on Node `upgrade` requests: session, authentication, CORS, and rate-limiting middleware cannot authorize the socket. The optional `native` callback receives a raw `IncomingMessage`, not an Express request, so `trust proxy` and `req.protocol` do not apply. Authenticate upgrades with Arc authentication handlers or a trusted session lookup in that callback. ## Fastify Call `await app.register(cratisArc, { arc })` before listening (`webSockets` defaults to `true`; `cratisArc` comes from `@cratis/arc.fastify`). A shared `@fastify/websocket` can be registered before or after Arc; real upgrade checks cover both orders. A `prefix` in the registration scopes HTTP and WebSocket paths without changing Arc’s generated routes. Fastify’s `onRequest`, `preValidation`, and `preHandler` hooks run before the upgrade. `app.close()` closes the listener but does **not** dispose the Arc application or its services. Use `shutdownArcHost(arc.server, () => app.close())` from `@cratis/arc.core/hosting` to coordinate participant shutdown before socket closure; see [Fastify](/arc/backend/typescript/hosts/fastify/). ## Hono server.ts ```typescript import { Hono } from 'hono'; import { cratisArc, serveCratisArc } from '@cratis/arc.hono'; import { arc } from './arc.js'; const app = new Hono(); app.use(cratisArc(arc)); const hosted = await serveCratisArc(app, arc, { port: 3000, hostname: '127.0.0.1' }); ``` Call `await hosted.dispose()` at shutdown, before disposing `arc`. If your application already has a `createNodeWebSocket({ app })` helper, call `createHonoWebSockets(app, arc.server, undefined, helper)` on the same Hono app after registering `cratisArc` middleware; call **only** `helper.injectWebSocket(listener)`, then dispose the Arc bridge before closing the listener because Arc does not own that shared listener. Ordinary GET requests pass through the WebSocket route to your handlers. Hono middleware runs for upgrades, and the Node TLS socket supplies `secure` unless trusted native context overrides it. `@hono/node-server` is needed only for this Node host; other Hono runtimes need their own verified bridge. ## Frame limits Arc rejects oversized inbound WebSocket frames with close code 1009 on every adapter, even with shared WebSocket infrastructure. Configure a shared plugin or helper’s `maxPayload` at or below `query.maxObservableInboundFrameBytes` (64 KiB by default) so it rejects them before delivery. The other observable limits are in [Configuration](/arc/backend/typescript/configuration/#observable-query-limits). ## Origin checks Each bridge checks exact raw paths and the configured `query.allowedOrigins` before accepting an upgrade. * By default, a browser `Origin` that is present must match the trusted transport’s scheme and authority. * `query: { allowedOrigins: ['http://localhost:5173'] }` replaces that default with an explicit list. Include your application’s own origin when it must stay allowed. * An async predicate `(origin, request, native) => boolean` can implement a host policy. * An absent `Origin` is permitted for native clients. It is **not** proof of authentication. ## Behind a TLS-terminating proxy Arc never trusts `X-Forwarded-*` by itself. A trusted `native` callback can set `secure` and `authority` when a verified proxy terminates TLS. Validate the proxy connection against a fixed list before reading forwarded values: ```typescript middleware.injectWebSocket(listener, request => { if (request.socket.remoteAddress !== '127.0.0.1') throw new Error('Untrusted proxy'); const protocol = request.headers['x-forwarded-proto']; const host = request.headers['x-forwarded-host']; if (protocol !== 'https' || host !== 'app.example.com') throw new Error('Invalid forwarded authority'); return { secure: true, authority: host }; }); ``` Adapt the trusted proxy address and host to your deployment; never accept these headers from direct clients. This example does not supply `remoteAddress`, so anonymous per-caller connection and subscription caps group callers by the proxy’s address. For individual anonymous caps, validate `X-Forwarded-For` against your trusted proxy chain and supply the verified client address as `remoteAddress`. ## Related * [Observable queries](/arc/backend/typescript/queries/observable-queries/) * [Subscribe to an observable query](/arc/backend/typescript/queries/subscribing-to-observable-queries/) * [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/) * [Native principal](/arc/backend/typescript/hosts/native-principal/) # Identity > Give the frontend the signed-in user's name, roles, and application details through /.cratis/me, and keep that display data apart from authentication and authorization. Ada signs in to your task application. The header should say “Hello, Ada”, the Archive button should only appear for editors, and the page should know which customer she is working for. Without help you end up writing a `/me` route, repeating the role list in the frontend, and deciding by hand what the browser may cache. Arc gives you one endpoint for that job. You write a small provider that turns the authenticated caller into the details your UI needs. Arc serves the result at `GET /.cratis/me` and sets a cookie that the published `@cratis/arc` client reads, so every component can ask “who is this?” without another request. ## Three jobs, three places Identity details sit next to two other concerns. Keep them apart, because each one trusts different evidence: | Job | Question | Where it lives | | --- | --- | --- | | Authentication | Who is calling? | [Authentication handlers](/arc/backend/typescript/core/authentication/) or a [native principal](/arc/backend/typescript/hosts/native-principal/) | | Authorization | May this caller run this command or query? | [Decorators and policies](/arc/backend/typescript/core/authorization/) on each operation | | Identity details | What should the UI show about this caller? | An identity details provider, served at `/.cratis/me` | ``` Request with a credential Authentication handler Verified principal Operation authorization Identity details provider /.cratis/me JSON and cookie Frontend display ``` The provider only ever sees a principal that authentication already verified. Nothing it returns flows back into authorization. ## Provide identity details Write a provider class and let discovery find it, or add it with `builder.add(...)`: Features/Identity/GreetingDetails.ts ```typescript import { field } from '@cratis/fundamentals'; import { identityDetailsProvider, type ExecutionContext, type IdentityDetailsProvider, type Principal } from '@cratis/arc.core'; export class UserDetails { @field(String) greeting!: string; } @identityDetailsProvider() export class GreetingDetails implements IdentityDetailsProvider { readonly detailsType = UserDetails; provide(principal: Principal, context: ExecutionContext): UserDetails { return { greeting: `Hello ${principal.name ?? principal.id} (${context.tenantId ?? 'no tenant'})` }; } } ``` `detailsType` tells Arc the shape of the details, so it can validate what `provide` returns, describe it at `/.cratis/identity-details/schema`, and let the [proxy generator](/arc/backend/typescript/proxy-generation/) emit a matching frontend class. With an [authentication handler](/arc/backend/typescript/core/authentication/) that recognizes Ada and a request for tenant `acme`, `GET /.cratis/me` answers: ```json {"id":"ada","name":"Ada","isAuthenticated":true,"isAuthorized":true,"roles":["Editor"],"details":{"greeting":"Hello Ada (acme)"}} ``` The same response sets `.cratis-identity=; Path=/; SameSite=Lax`. The `id`, `name`, and `roles` come from the verified principal. Only `details` comes from your provider, and Arc runs it again on every call to `/.cratis/me`. The identity cookie is not a credential `.cratis-identity` is unsigned and readable by JavaScript. It exists so the frontend can display the user. Never use it to authenticate or authorize anything; Arc itself never reads it. ## What you have so far * Authentication decides who the caller is. Your provider only adds display details. * `/.cratis/me` returns the principal plus those details, and caches them in a cookie for the frontend. * Commands and queries keep their own authorization. Hiding a button in the UI protects nothing. ## Go further | Topic | What it covers | | --- | --- | | [How identity details are served](/arc/backend/typescript/identity/provider-flow/) | Registration choices, every `/.cratis/me` answer, the cookie format, and how caching works | | [Identity contracts](/arc/backend/typescript/identity/contracts/) | The provider contract, the principal it receives, the `/.cratis/me` shape, and the differences from Arc on .NET | | [Entra API bearer tokens](/arc/backend/typescript/identity/entra-bearer/) | Tenant-specific JWT verification, app roles and delegated scopes | | [AuthProxy and EasyAuth](/arc/backend/typescript/identity/authproxy-easyauth/) | Forwarded principals, ingress isolation and durable user keys | | [Observable transport authentication](/arc/backend/typescript/identity/observable-authentication/) | Credentials on WebSocket upgrades and SSE requests, without named schemes | | [Show identity in a React frontend](/arc/backend/typescript/identity/frontend/) | `useIdentity`, `RequireRole`, typed details, and refreshing after a change | | [Identity across services](/arc/backend/typescript/identity/topologies/) | One service, several services behind a gateway, or a dedicated identity service | | [Simulate a signed-in user locally](/arc/backend/typescript/identity/local-development/) | Try different users, roles, and tenants on a loopback development host | | [Development users and tenants](/arc/backend/typescript/identity/development-users-and-tenants/) | Fixture lists for local user and tenant pickers | Next, read [how identity details are served](/arc/backend/typescript/identity/provider-flow/) to see what happens between the request and the cookie. # Accept AuthProxy or EasyAuth identity headers > Run microsoftIdentityPlatform only behind a sanitizing authentication ingress, block backend bypasses, and choose a durable Entra user key. Use `microsoftIdentityPlatform()` when AuthProxy or Azure App Service EasyAuth authenticates the caller **before** forwarding the request to Arc. Arc consumes the forwarded principal; it does not verify a token, contact the provider, or validate a login session in this handler. No separate Arc provider SDK is needed. Base64 is not authentication An attacker who reaches this handler directly can construct a principal and grant themselves roles. Removing reserved claims inside the payload does **not** make caller-supplied principal headers safe. ## Establish the ingress boundary first For a self-hosted AuthProxy deployment, the request path must be: public HTTPS ingress, AuthProxy authentication, then a private Arc listener. Configure the **public boundary before authentication** to remove every incoming header whose name starts with `x-ms-client-principal`, case-insensitively. This includes the principal payload, ID, name, IDP and any suffix, not just the three headers Arc currently reads. After successful authentication, only the trusted proxy may write replacement identity headers. Unauthenticated requests must not carry identity headers upstream. Do not strip the verified headers on the internal hop **after** the authentication proxy. Restrict who can reach that hop and the backend instead: * On a shared host, bind Arc to `127.0.0.1` and let only a trusted local proxy connect. Other processes on that host remain inside the trust boundary; loopback is not process isolation. * With containers, do not publish the Arc port. Put Arc on a private network with only the authentication proxy and enforce network policy/firewall rules. Do not assume a private IP or a Kubernetes Service by itself blocks other workloads. * Block public backend hostnames, load-balancer targets, alternate ports and WebSocket paths that bypass authentication. Protect the proxy-to-Arc connection with TLS/mTLS when it crosses a network trust boundary. * For managed App Service EasyAuth, enable authentication on the app and verify there is no path around that module. Microsoft states that [external requests cannot set the injected identity headers](https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-user-identities#access-user-claims-in-app-code). This is an App Service guarantee, **not** a guarantee of an arbitrary reverse proxy or a copied header. Provider selection, login callbacks, session storage and logout belong to AuthProxy/EasyAuth. Configure their tenant, client registration and session requirements using that product’s deployment instructions. Arc’s display cookie is not that session. ## Configure the private Arc backend In an [Arc Node project](/arc/backend/typescript/core/), compile and run this entry point behind the boundary above. It binds only to loopback. Configure the proxy’s backend target as `http://127.0.0.1:3000`; this topology requires both processes to share a trusted host/network namespace. main.ts ```typescript import { ArcServer, defineQuery, microsoftIdentityPlatform, runArc } from '@cratis/arc.core'; import { z } from 'zod'; const server = new ArcServer({ authentication: [microsoftIdentityPlatform()], queries: [defineQuery({ name: 'RoleReports', schema: z.object({}), authorization: { roles: ['Reports.Read'] }, perform: () => 'role granted' })], identityDetails: { schema: z.object({ greeting: z.string() }), provide: principal => ({ greeting: `Hello ${principal.name ?? principal.id}` }) } }); const host = await runArc(server, { host: '127.0.0.1', port: 3000 }); for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.once(signal, () => { void host.shutdown(); }); } ``` Apply authorization to every protected command and query. Merely registering the handler does not make every operation require authentication. Do not mix forwarded-header authentication into a publicly reachable Bearer route as a convenient fallback. ## Know the forwarded fields Arc consumes All three headers must be present: `x-ms-client-principal`, `x-ms-client-principal-id`, and `x-ms-client-principal-name`. Missing headers leave the request anonymous. An empty ID, malformed Base64/JSON or invalid payload fails authentication. Arc currently interprets them as follows: | Arc value | Source | | --- | --- | | `principal.id` | `x-ms-client-principal-id`, not the payload’s `userId` | | `principal.name` | Decoded payload `userDetails`, defaulting to `""`; not the name header | | `principal.roles` | Payload `userRoles` plus claims named `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` | | `claims.sub` and the .NET name-identifier claim | Rewritten from the forwarded ID | | .NET name claim | Rewritten from `userDetails` | | `urn:cratis:arc:identity:provider` | Payload `identityProvider`, only when nonblank | Inbound `sub` and `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` are removed with **case-sensitive** matching. Provider-claim removal is ASCII case-insensitive. Other claim names remain case-sensitive and are copied; duplicate claim types retain their last value in the dictionary, while standard role claims also accumulate in the roles list. Never authorize against a differently cased lookalike reserved claim. ### App Service envelope versus AuthProxy extensions Microsoft’s documented App Service envelope has `auth_typ`, `claims`, `name_typ` and `role_typ`. Arc accepts its `claims` array, but does not use those three metadata fields or the `x-ms-client-principal-idp` header. The `identityProvider`, `userDetails` and `userRoles` fields are a different, extended envelope shape; do not assume every EasyAuth deployment supplies them. With the plain App Service envelope, authentication still uses the ID header and roles still work through the standard Microsoft role claim. The displayed name is empty and Arc’s provider metadata claim is absent. Custom `role_typ` mappings are not interpreted. Confirm the actual envelope produced by your ingress; if you need a display name, provide an application-owned display field through identity enrichment from a trusted directory lookup. Do not treat a blank name as failed authentication or rewrite core authorization to trust display details. ## Choose a durable user key `identityProvider` (often a label such as `aad`), `auth_typ`, the IDP header, and Entra’s `idp` claim describe an authentication provider. They are **not** a durable user key or a verified issuer/tenant boundary. For an Entra directory user, persist the verified **tenant ID (`tid`) plus object ID (`oid`)**. With App Service claim mapping, these commonly appear in the forwarded claim dictionary as `http://schemas.microsoft.com/identity/claims/tenantid` and `http://schemas.microsoft.com/identity/claims/objectidentifier`. Confirm the mapping at your trusted ingress; Arc does not rename these claims. This key is safe only when the trusted ingress allows Entra as its sole identity provider and restricts it to the expected tenant(s). Otherwise, check the forwarded tenant against an explicit allowlist and reject identities from other enabled providers before using this key. Do not infer a verified Entra identity from a provider label or from the presence of these claim names alone. Fail your application’s lookup if the required identifiers are missing or ambiguous rather than falling back to email, name, or a provider label. A verified `sub`, scoped to its issuer/application, is appropriate for an application-local identifier, but is pairwise and changes across application registrations. In this handler **Arc rewrites `sub` to the forwarded ID**; it is no longer necessarily the original token’s subject. Do not assume that the forwarded ID equals the Entra object ID either. Retain `tid`/`oid` explicitly for cross-service directory relationships, and keep guest identities in different tenants separate. Microsoft’s [stable identity guidance](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference#use-claims-to-reliably-identify-a-user) explains this distinction. App-only callers have service-principal object IDs, not human user IDs. ## Verify your actual deployment Before exposing this configuration, test from **outside** the trusted network boundary. A unit test that injects trusted headers directly into Arc cannot verify your ingress rules. | Probe | Required result | | --- | --- | | Signed-in caller with `Reports.Read` through the public ingress | `/api/role-reports` succeeds; `/.cratis/me` identifies that caller | | Signed-in caller without that role | Role query returns 403 | | Anonymous caller supplies a forged payload, ID, name and IDP header | Ingress challenges/rejects, or Arc returns 401; no forged identity appears | | Signed-in unprivileged caller adds forged admin headers, mixed-case names and duplicate header values | Ingress removes them and forwards only its verified identity; no role escalation | | Caller connects directly to the Arc backend address/port or an alternate hostname | Connection is blocked before it reaches Arc | | Request carries only `.cratis-identity`, without the proxy’s real login session | No authenticated principal; `/.cratis/me` cannot succeed | | The same probes use an observable upgrade or SSE hub path | The same ingress boundary holds; see [observable authentication](/arc/backend/typescript/identity/observable-authentication/) | Use a nonproduction account and inspect only the minimum identity fields; do not log tokens or full principal headers. Test the wildcard stripping rule at the public boundary and the network restrictions independently. Authenticated traffic succeeding is not evidence that either protection works. The repository specs exercise the handler, reserved claims, role authorization, identity enrichment and display-cookie replay without a provider connection. They do not certify an AuthProxy or Azure deployment. The deployment probes above must be run against the infrastructure you will expose. ## Related * [Entra API bearer tokens](/arc/backend/typescript/identity/entra-bearer/) when Arc, rather than a proxy, verifies the token * [Identity contracts](/arc/backend/typescript/identity/contracts/) for `/.cratis/me` and the display cookie * [Observable transport authentication](/arc/backend/typescript/identity/observable-authentication/) # Identity contracts > Reference for the IdentityDetailsProvider contract, its two registrations, the Principal and ExecutionContext it receives, the /.cratis/me response shape, and how the principal-first trust model differs from Arc on .NET. This page lists the exact shapes involved in identity details: what your provider implements, what it receives, what `/.cratis/me` returns, and where each rule lives in the source. For the walkthrough, read [Identity](/arc/backend/typescript/identity/) and [How identity details are served](/arc/backend/typescript/identity/provider-flow/). ## IdentityDetailsProvider Source: `Source/Core/identity/IdentityDetailsProvider.ts`. Import the type from `@cratis/arc.core`. | Member | Type | Meaning | | --- | --- | --- | | `detailsType` | A class with `@field` declarations | The shape of `details`; Arc derives its schema from the fields | | `schema` | `z.ZodType` | An explicit Zod schema for `details`; used instead of `detailsType` when both are set | | `provide(principal, context)` | Returns `unknown` or `Promise` | Returns the details, or `undefined` to deny the caller | A provider must declare `detailsType` or `schema`, and a `provide` function. Otherwise `build()` fails with `Identity details require a provider schema` (`Source/Core/validateOptions.ts`). Prefer `detailsType`: the proxy generator turns it into a frontend class. ## Registration | Registration | How | Instances | | --- | --- | --- | | Decorated class | `@identityDetailsProvider()` on a class, added with `builder.add(...)` or found by `builder.discover(...)` | A new instance for every request, constructed with no arguments | | Option object | `identityDetails: { detailsType, provide }` or `{ schema, provide }` in `ArcApplication.createBuilder(...)` or `new ArcServer(...)` | The object you pass | Sources: `Source/Core/identity/discoverIdentityDetails.ts`, `Source/Core/build/buildRegistered.ts`, and `identityDetails` in `Source/Core/ArcOptions.ts`. `build()` fails when you supply both an option object and a decorated class (`Explicit and discovered identity details providers cannot be combined`), and when discovery finds more than one decorated class (`Multiple identity details providers found`). A decorated class has no constructor injection. Resolve services inside `provide` with `currentServices()`; Arc runs `provide` in a service scope created for the request and disposes it afterwards. ## What provide receives `Principal` (`Source/Core/identity/Principal.ts`), produced by your [authentication handler](/arc/backend/typescript/core/authentication/) or a [native principal](/arc/backend/typescript/hosts/native-principal/): | Property | Type | | --- | --- | | `id` | `string` | | `name` | `string`, optional | | `roles` | `readonly string[]` | | `isAuthenticated` | `boolean` | | `claims` | `unknown`, optional | | `scheme` | `string`, optional | `ExecutionContext` (`Source/Core/execution/ExecutionContext.ts`, also exported from `@cratis/arc.core`) carries `correlationId`, `principal`, `tenantId`, `remoteAddress`, `signal`, and `allowedSeverity`. Tenant resolution has already run, so `context.tenantId` is the tenant this request selected. ## The /.cratis/me response Source: `Source/Core/http/handleIdentity.ts`. The endpoint is mapped only when a provider is registered (`Source/Core/http/createRouteTable.ts`). | Property | Value | | --- | --- | | `id` | `principal.id` | | `name` | `principal.name`, or `""` when the principal has none | | `isAuthenticated` | Always `true` | | `isAuthorized` | Always `true` | | `roles` | `principal.roles` | | `details` | What `provide` returned, parsed by the provider’s schema | Every other outcome is a status code, not an identity with a `false` flag: | Outcome | Status and body | | --- | --- | | No authenticated principal, or the credential was rejected | 401 `{"error":"Unauthorized"}` | | Tenant missing or invalid, including a missing tenant with `tenancy.required` | 400 `{"error":"Invalid tenant request"}` | | Not a member under `tenancy.membershipClaim` | 403 `{"error":"Forbidden"}` | | `provide` returned `undefined` | 403 `{"error":"Forbidden"}` | | `provide` threw, the details failed the schema, or the cookie would exceed 4096 bytes | 500 `{"error":"An unexpected error occurred"}` | Every answer carries `Cache-Control: no-store`. A 200 also sets `.cratis-identity=; Path=/; SameSite=Lax`, plus `Secure` on a trusted HTTPS transport. The cookie holds the same JSON as the body, with non-ASCII characters escaped. `GET /.cratis/identity-details/schema` returns the JSON Schema of `details`. ## Principal first, cookie never The server never reads `.cratis-identity`. Each call to `/.cratis/me` authenticates the request, resolves the tenant, and runs `provide` again (`Source/Core/http/handleRequest.ts`). The cookie is a display cache for the browser client, and commands and queries authorize against the verified principal only. Arc on .NET works differently, and Arc for TypeScript deliberately does not copy it. In Arc 22.45.0, the .NET `/.cratis/me` endpoint returns a nonempty `.cratis-identity` cookie’s content before it consults the provider (`Source/DotNET/Arc.Core/Identity/IdentityProvider.cs`), and `IIdentityProvider.ModifyDetails` rewrites that cookie. Because the cookie is unsigned and editable by JavaScript, that path lets a browser choose what `/.cratis/me` reports. Arc for TypeScript has no such path, and no server-side way to modify details. To store a user preference, send a command, keep the value in your own storage, and return it from `provide`. ## Compared with Arc on .NET | Concern | Arc on .NET | Arc for TypeScript | | --- | --- | --- | | Provider contract | `IProvideIdentityDetails.Provide(IdentityProviderContext)` | `IdentityDetailsProvider.provide(principal, context)` | | Provider input | `IdentityProviderContext`: `Id`, `Name`, and `Claims` as string pairs | `Principal` with `roles` and structured `claims`, plus the `ExecutionContext` | | Provider output | `IdentityDetails(IsUserAuthorized, Details)` | The details, or `undefined` to deny | | Details shape | Any object | Validated against `detailsType` or `schema` | | Discovery endpoints (`/.cratis/commands`, `/.cratis/queries`, `/.cratis/users`, `/.cratis/tenants` and `/.cratis/identity-details/schema`) | Require authentication outside Development by default in 22.45.0 | Same default, opt-out, and optional roles; also covers `/openapi.json`. See [Discovery access](/arc/backend/typescript/introspection/#production-access). | | Dependencies | Constructor injection | `currentServices()` inside `provide` | | Denied caller | 403 from `IsUserAuthorized: false` | 403 from `undefined` | | Cookie on the server | Read first when present | Never read | | Modify details | `IIdentityProvider.ModifyDetails` | Not available | ## Related * [How identity details are served](/arc/backend/typescript/identity/provider-flow/) * [Show identity in a React frontend](/arc/backend/typescript/identity/frontend/) * [Identity details schema](/arc/backend/typescript/introspection/identity-details-schema/) # Development users and tenants > Offer fixture users and tenants to local development tooling through /.cratis/users and /.cratis/tenants, and keep them out of production. Local development tools, such as a user or tenant picker, need something to pick from. Hard-coding that list in the tool means it drifts from your application. Arc serves two discovery routes, anonymous by default only in Development, which return nothing until you opt in with fixture data from your own code. ## Opt in ```typescript const builder = ArcApplication.createBuilder({ environmentName: 'Development', development: true, developmentUsers: () => [{ microsoftIdentity: { identityProvider: 'development', userId: 'ada', userDetails: 'Ada', userRoles: ['editor'], claims: [{ typ: 'tenants', val: 'acme' }] } }], developmentTenants: () => [{ id: 'acme', name: 'Acme' }] }); ``` `GET /.cratis/users` and `GET /.cratis/tenants` return `[]` until you set `development: true` and supply `developmentUsers(context)` or `developmentTenants(context)`. Each option takes a provider function, a promise-returning function, or an array of them; results are aggregated in order. | Route | Entry shape | | --- | --- | | `/.cratis/users` | `{ microsoftIdentity: { identityProvider, userId, userDetails, userRoles, claims: [{ typ, val }] }, details? }` | | `/.cratis/tenants` | `{ id, name }` | Results are capped at 100 entries and 32 KiB, keep provider order and duplicates, and an invalid or failing provider answers a generic 500. Each request gets its own service scope. Fixtures only Both routes follow the [discovery access policy](/arc/backend/typescript/introspection/#production-access). They are anonymous in Development by default; the `development` flag alone does not make them anonymous. Never return secrets, production user inventories, or real tenant memberships. A development user entry is not a credential and does not authenticate anyone. Tenant resolution is separate: listing a tenant here does not select or authorize it. See [Tenancy](/arc/backend/typescript/tenancy/). Tenancy rules apply to these routes too The discovery routes resolve a tenant like every other request. With `tenancy.required`, an anonymous request without a tenant answers 400. With `tenancy.membershipClaim`, a request that names a tenant answers 403, because an anonymous caller has no membership claim. Leave `required` off in the local configuration that serves a picker. ## What a picker does with the list A tool such as [Lens](/tools/lens/) reads both routes to fill its pickers, then sends identity and tenant headers with your application’s requests. Arc only turns those identity headers into a principal when you registered `microsoftIdentityPlatform()`. [Simulate a signed-in user locally](/arc/backend/typescript/identity/local-development/) shows that setup on a loopback host. ## Related * [Identity](/arc/backend/typescript/identity/) * [Simulate a signed-in user locally](/arc/backend/typescript/identity/local-development/) * [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/) # Authenticate an API with Microsoft Entra ID > Pin a tenant, API audience and signing keys with jwtBearer, then authorize app roles and delegated scopes without a provider SDK. Use `jwtBearer()` when your Arc API receives **access tokens for that API**. This recipe validates tokens on the backend; it does not implement interactive sign-in, callbacks, or token refresh. No Microsoft provider SDK is required. Start with an [Arc Node application](/arc/backend/typescript/core/). Register a single-tenant API and a **separate client application** in Entra ID. On the API registration, expose a delegated scope named `Reports.Read` and an app role with the value `Reports.Read`. Grant the client the appropriate permission and consent; assign the app role to the user or service principal that needs it. The equal names below deliberately demonstrate that scopes and roles are separate permissions. ## Pin the token format, not the login endpoint For this example, configure the API registration’s `api.requestedAccessTokenVersion` as `2`. The **resource registration** controls the access-token version, not whether the client used a v1.0 or v2.0 authorization endpoint. Confirm the issuer and `jwks_uri` in the tenant-specific OpenID configuration before deployment. For the public Microsoft cloud, using your directory’s GUID as `TENANT_ID`: | Access-token version | Exact issuer | API audience | Tenant-specific JWKS URI | | --- | --- | --- | --- | | `2.0` | `https://login.microsoftonline.com/TENANT_ID/v2.0` | API **Application (client) ID**, a GUID | `https://login.microsoftonline.com/TENANT_ID/discovery/v2.0/keys` | | `1.0` | `https://sts.windows.net/TENANT_ID/` (including the trailing slash) | API App ID URI **or API client ID**, depending on how the token was requested | `https://login.microsoftonline.com/TENANT_ID/discovery/keys` | The v2 metadata endpoint is `https://login.microsoftonline.com/TENANT_ID/v2.0/.well-known/openid-configuration`; v1 omits `/v2.0`. A v2 client can request `api://API_CLIENT_ID/Reports.Read`, but the resulting v2 access token’s `aud` is the API GUID, **not** `api://API_CLIENT_ID`. For v1, pin the actual documented audience for your API; accept an explicit list only if you intentionally support both forms for that same API. Never substitute the frontend client ID, Microsoft Graph’s audience, `common`, or `organizations`. Microsoft’s [v1 discovery document](https://login.microsoftonline.com/common/.well-known/openid-configuration) shows the `https://sts.windows.net/{tenantid}/` issuer template; resolve it to your pinned tenant GUID, rather than trusting arbitrary tenants. These forms follow Microsoft’s [access-token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference), [access-token version and validation guidance](https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens), and [OpenID metadata reference](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#fetch-the-openid-configuration-document). Sovereign clouds and applications with custom signing keys need their own verified metadata; this recipe does not cover them. Arc does not perform OIDC discovery or tenant-independent signing-key issuer validation for you. ## Configure the API Set `ENTRA_TENANT_ID` and `ENTRA_API_CLIENT_ID` to the directory GUID and **API** application GUID. Add this entry point to the project, compile it with the project’s TypeScript build, then run its emitted JavaScript with Node. It binds to loopback on port 3000; expose it only through your HTTPS ingress. main.ts ```typescript import { ArcServer, defineQuery, jwtBearer, runArc } from '@cratis/arc.core'; import { z } from 'zod'; const tenant = z.string().uuid().parse(process.env.ENTRA_TENANT_ID); const audience = z.string().uuid().parse(process.env.ENTRA_API_CLIENT_ID); const server = new ArcServer({ authentication: [jwtBearer({ issuer: `https://login.microsoftonline.com/${tenant}/v2.0`, jwksUrl: new URL(`https://login.microsoftonline.com/${tenant}/discovery/v2.0/keys`), audience, algorithms: ['RS256'] })], authorizationPolicies: { ReportsRead: principal => { const claims = principal.claims as Record | undefined; return typeof claims?.scp === 'string' && claims.scp.split(' ').includes('Reports.Read'); } }, queries: [ defineQuery({ name: 'RoleReports', schema: z.object({}), authorization: { roles: ['Reports.Read'] }, perform: () => 'role granted' }), defineQuery({ name: 'ScopeReports', schema: z.object({}), authorization: { policy: 'ReportsRead', authenticated: true }, perform: () => 'scope granted' }) ], identityDetails: { schema: z.object({ greeting: z.string() }), provide: principal => ({ greeting: `Hello ${principal.name ?? principal.id}` }) } }); const host = await runArc(server, { host: '127.0.0.1', port: 3000 }); for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.once(signal, () => { void host.shutdown(); }); } ``` `RS256` is an explicit asymmetric allowlist, not an algorithm read from an untrusted token. `jwtBearer()` rejects unsigned and symmetric algorithms in configuration, verifies signatures using the pinned HTTPS JWKS, checks issuer, audience, expiration and any not-before claim, and requires a nonempty subject. Clock tolerance defaults to zero. Key-fetch failure rejects the credential rather than allowing access. See [authentication options](/arc/backend/typescript/core/authentication/#verify-jwt-bearer-tokens). For v1 tokens, change **all three** issuer, JWKS URI and audience settings to the v1 row above. Choose one token contract per configuration. Two default `jwtBearer()` handlers do not implement fallback between issuers: a recognized but invalid Bearer token stops the handler chain. ## Access tokens are not ID tokens Send the API access token as `Authorization: Bearer `. An ID token establishes a client application’s sign-in session; it is not an API credential. Microsoft documents that an [ID token’s audience is the client application ID](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference). Separate API and frontend registrations let the API reject the frontend’s ID token by audience, even when issuer, signing key and role names match. Separate registrations alone are not enough: the API’s own app registration must not be usable for interactive sign-in. Configure no redirect URIs, disable public-client flows and implicit ID-token issuance, and keep interactive sign-in on the separate client registration so no ID token can carry the API’s audience. **Signature verification alone cannot distinguish them.** Both token kinds can have `typ: JWT`, and ID tokens can contain `roles`. `jwtBearer()` is a generic verifier, not an Entra token-purpose detector: if you reuse the same registration/audience for the client and API, it can accept an otherwise valid ID token. Do not rely on `typ`, the presence of `roles`, or a nonce heuristic to prevent that confusion. Keep the registrations separate, prevent interactive sign-in on the API registration, and require the intended permission on each operation. Authentication is not API authorization Pinning issuer, audience and signature authenticates any caller in the tenant that holds a valid token for the API. This includes app-only client-credentials tokens with neither `roles` nor `scp`. `authenticated: true` alone is not authorization for an Entra API: require an app role or a delegated scope policy on every operation. Consider enabling **Assignment required** on the API’s enterprise application as an additional restriction; it does not replace operation-level permission checks. ## Keep app roles and scopes separate * `roles` is an array. Entra uses it for assigned user app roles and for application permissions in app-only tokens. Arc maps it directly to `principal.roles`; `@roles('Reports.Read')` or the role requirement above checks that list. A role does not prove that the caller is a human. * `scp` is a space-separated string of **delegated** scopes. Arc leaves it in `principal.claims`; it does not promote scopes to roles. `ReportsRead` requires the exact scope token, not a substring. With decorators, select the same policy using `@authorize('ReportsRead')`. * Role-only and scope-only operations in this example are alternatives. If an operation must require both, declare both requirements; see [authorization policies](/arc/backend/typescript/core/authorization/). Application-specific client allowlists or ownership rules are additional checks, not implied by an API audience. `jwtBearer()` sets `principal.id` from `sub`, a pairwise identifier, not `oid`. For durable Entra directory relationships shared across services, use verified `tid` plus `oid`; do not key users by email or display name. See [durable identity keys](/arc/backend/typescript/identity/authproxy-easyauth/#choose-a-durable-user-key). ## Check the result Issue GET requests with the authorization header to the following paths: | Credential | `/.cratis/me` | `/api/role-reports` | `/api/scope-reports` | | --- | --- | --- | --- | | Valid API token, `roles: ["Reports.Read"]`, no `scp` | 200, subject/name/roles and greeting | 200 | 403 | | Valid API token, `scp: "Reports.Read"`, no roles | 200, empty roles and greeting | 403 | 200 | | Wrong issuer or audience, expired token, or the separate frontend’s ID token | 401 | 401 | 401 | | Only the `.cratis-identity` cookie from a successful call | 401 | 401 | 401 | The cookie is display data, never a credential. Identity enrichment runs only **after** authentication and cannot establish a principal. The repository’s `Source/Core/authentication/for_identityRecipes` specs exercise these boundaries with locally generated signing keys and an intercepted JWKS response; they do not contact Entra or validate your tenant’s deployment. For live queries, continue with [observable transport authentication](/arc/backend/typescript/identity/observable-authentication/), not named authentication schemes. # Show identity in a React frontend > Read the signed-in user and typed identity details with @cratis/arc.react, hide UI by role while the identity loads, and refresh or clear the cached identity. Your backend now answers `/.cratis/me`. On the React side you want three things: the user’s name in the header, controls hidden from people who cannot use them, and a way to pick up changes without a full reload. The published `@cratis/arc.react` client already does the fetching and caching. You connect it to the details class your backend declares. This page uses `@cratis/arc` and `@cratis/arc.react` 22.45.0, the client versions the [proxy generator](/arc/backend/typescript/proxy-generation/getting-started/) targets. ## Give the client your details type Generate proxies from the backend that holds your identity provider. In a co-located layout with `src/` beside `Features/`, the generator emits the provider’s `detailsType` beside its slice as a frontend class with the same `@field` declarations. For a [separate output folder](/arc/backend/typescript/proxy-generation/getting-started/#separate-output-folders), adjust the imports to that folder instead: Features/Identity/UserDetails.proxy.ts (generated excerpt) ```typescript import { field } from '@cratis/fundamentals'; export class UserDetails { @field(String) greeting!: string; } ``` Pass it to the `Arc` component at the root of your application: src/App.tsx ```tsx import { Arc } from '@cratis/arc.react'; import { UserDetails } from '../Features/Identity/UserDetails.proxy'; import { Header } from './Header'; export function App() { return
; } ``` `Arc` already contains an identity provider. On mount it reads the `.cratis-identity` cookie. When the cookie is missing, it calls `/.cratis/me` with the same `httpHeadersCallback` headers your commands and queries send, and the response sets the cookie for next time. ## Read the identity src/Header.tsx ```tsx import { useIdentity } from '@cratis/arc.react/identity'; import { UserDetails } from '../Features/Identity/UserDetails.proxy'; export function Header() { const identity = useIdentity(UserDetails); if (identity.isLoading) return

Loading…

; if (!identity.isSet) return

Please sign in.

; return

{identity.details.greeting}

; } ``` `useIdentity(UserDetails)` returns the identity with `details` typed as `UserDetails`, plus `id`, `name`, `roles`, and `isInRole(role)`. Check `isLoading` first: before the first answer arrives, `isSet` is `false` for a signed-in user too. After loading, `isSet` is `false` when `/.cratis/me` answered anything other than 200, such as 401 for an anonymous caller or 403 from a provider that returned `undefined`. ## Hide controls by role `RequireRole` renders its children only for a signed-in caller with one of the roles, and keeps the loading and denied states apart: src/ArchiveButton.tsx ```tsx import { RequireRole } from '@cratis/arc.react/identity'; export function ArchiveButton() { return Loading…

} forbidden={

Read-only

}>
; } ``` The roles come from the verified principal on the server, so they match what `@roles('Editor')` checks. The check in the browser still only hides UI. Anyone can edit the cookie and render the button; the command behind it answers 403 because the server authorizes against the real principal. Protect every command and query on the server, as described in [Authorization policies and schemes](/arc/backend/typescript/core/authorization/). ## Refresh after a change The client keeps using the cookie until something replaces it. After an action that changes what `/.cratis/me` would return, such as a role grant, a profile edit, or a switch to another tenant, call `refresh()`: ```tsx import { useIdentity } from '@cratis/arc.react/identity'; import { UserDetails } from '../Features/Identity/UserDetails.proxy'; export function SwitchTenant({ tenant }: { tenant: string }) { const identity = useIdentity(UserDetails); const select = async () => { localStorage.setItem('tenant', tenant); await identity.refresh(); }; return ; } ``` This example assumes the application’s `httpHeadersCallback` reads the tenant from `localStorage` and sends it as the `x-cratis-tenant-id` header. `refresh()` clears the cookie, calls `/.cratis/me`, and re-renders every component that uses the identity. While it runs, `isLoading` is `true` again. When `/.cratis/me` answers with an error status, the identity becomes unset. When the request itself fails, for example on a network error, the promise rejects and the previous identity stays in place. When a user signs out, call `clearIdentity()` from `useIdentity()`. It removes the cookie and resets the identity to unset without calling the server. ## Common mistakes * **Treating `isSet: false` as signed out during loading.** Show a loading state until `isLoading` is `false`, or signed-in users see the signed-out UI flash on every page load. * **Using only a type argument.** `useIdentity()` types the details but cannot deserialize them, because a type has no runtime field metadata. Pass the generated class, `useIdentity(UserDetails)`, so concepts, dates, and nested models arrive as their real types. * **Expecting the UI to notice server-side changes.** Nothing pushes identity changes to the browser. Call `refresh()` after the change, or after a fresh sign-in. ## Recap * `Arc detailsType={...}` plus `useIdentity(Type)` gives every component typed details from one cached request. * `RequireRole` hides UI and never replaces server authorization. * `refresh()` re-reads `/.cratis/me` after a change; `clearIdentity()` forgets the user on sign-out. The shared [React identity](/arc/frontend/react/identity/) page covers the rest of the client API, including default details and the raw context. To see how services share one identity, read [Identity across services](/arc/backend/typescript/identity/topologies/). # Simulate a signed-in user locally > Exercise different users, roles, and tenant memberships on a loopback development host with forwarded Microsoft identity headers, without mistaking Base64 assertions for verified tokens. You want to see how the application behaves for an editor, for a reader, and for someone who belongs to another tenant, before production sign-in exists. This guide turns on Arc’s Microsoft identity header handler on your development machine only, and sends it synthetic principals. This is identity **simulation**. Nothing here verifies a token. ## Understand the trust boundary first `microsoftIdentityPlatform()` reads the `x-ms-client-principal`, `x-ms-client-principal-id`, and `x-ms-client-principal-name` headers that Azure EasyAuth or a trusted ingress forwards. The principal header is Base64-encoded JSON. It has no signature, so anyone who can reach the server can claim to be anyone. Enable the handler only on a host bound to `127.0.0.1`, and only when you run it locally. In production, either verify real tokens with [`jwtBearer()`](/arc/backend/typescript/core/authentication/#verify-jwt-bearer-tokens), or accept these headers only behind an ingress that strips caller-supplied identity headers and blocks direct access to the backend. ## Steps 1. Register the handler only for local development. The standalone host binds `127.0.0.1` unless you pass another `host`: main.ts ```typescript import { ArcApplication, microsoftIdentityPlatform, TenantResolverType } from '@cratis/arc.core'; const development = process.env.NODE_ENV === 'development'; const builder = ArcApplication.createBuilder({ development, authentication: development ? [microsoftIdentityPlatform()] : [], tenancy: { sources: [TenantResolverType.Header], membershipClaim: 'tenants' } }); await builder.discover(new URL('./Features/', import.meta.url)); const app = await builder.build(); await app.run({ port: 3000 }); ``` `membershipClaim: 'tenants'` makes Arc check the selected tenant against the principal’s `tenants` claim, so you can test membership too. This guide assumes a command `ArchiveTask` in `Features/Tasks/` decorated with `@roles('Editor')`. 2. Save a synthetic principal as `principal.json`. Use invented values, never a real user’s: principal.json ```json { "identityProvider": "development", "userId": "ada", "userDetails": "Ada", "userRoles": ["Editor"], "claims": [{ "typ": "tenants", "val": "acme" }] } ``` `userRoles` become the principal’s roles. Each claim becomes an own claim on the principal, so `tenants` is what `membershipClaim` reads. `userDetails` becomes the principal’s `name`. 3. Encode it on one line, locally. Do not paste identity payloads into an online encoder: ```bash PRINCIPAL=$(python3 -c 'import base64,pathlib; print(base64.b64encode(pathlib.Path("principal.json").read_bytes()).decode())') ``` 4. Start the host with `NODE_ENV=development`, then send the three headers with a request: ```bash curl -X POST http://127.0.0.1:3000/api/tasks/archive-task \ -H 'content-type: application/json' \ -H 'x-cratis-tenant-id: acme' \ -H "x-ms-client-principal: $PRINCIPAL" \ -H 'x-ms-client-principal-id: ada' \ -H 'x-ms-client-principal-name: Ada' \ -d '{"id":"t1"}' ``` The command succeeds with `"isSuccess":true` and HTTP 200. ## Check each behavior Change one thing at a time and compare the status: | Request | Status | Why | | --- | --- | --- | | No identity headers | 401 | `ArchiveTask` requires a role and nobody is authenticated | | A principal header that is not valid Base64 JSON | 401 | The handler rejected the credential | | Ada, tenant `acme` | 200 | Ada has `Editor` and belongs to `acme` | | Ada, tenant `globex` | 403 | `globex` is not in Ada’s `tenants` claim | | A principal without `Editor`, tenant `acme` | 403 | The role check failed | With an [identity details provider](/arc/backend/typescript/identity/provider-flow/), `GET /.cratis/me` with the same headers returns Ada’s identity and sets the display cookie. ## Offer the users to a picker Tools that switch users for you, such as [Lens](/tools/lens/), read `/.cratis/users` and `/.cratis/tenants`. Their user entries use the same shape as `principal.json`, so one fixture serves both: ```typescript import { ArcApplication, microsoftIdentityPlatform, TenantResolverType } from '@cratis/arc.core'; const builder = ArcApplication.createBuilder({ development, authentication: development ? [microsoftIdentityPlatform()] : [], tenancy: { sources: [TenantResolverType.Header], membershipClaim: 'tenants' }, developmentUsers: () => [{ microsoftIdentity: { identityProvider: 'development', userId: 'ada', userDetails: 'Ada', userRoles: ['Editor'], claims: [{ typ: 'tenants', val: 'acme' }] } }], developmentTenants: () => [{ id: 'acme', name: 'Acme' }] }); ``` A picker only lists these entries. Arc honors the identity headers a tool then sends because `microsoftIdentityPlatform()` is registered, and for no other reason. See [Development users and tenants](/arc/backend/typescript/identity/development-users-and-tenants/) for the limits of these routes. Use a narrow scope in browser header tools If you add the headers with a browser extension instead of curl, restrict it to your local URLs, such as `http://127.0.0.1:5173/*`. A rule that applies to every site sends your synthetic identity everywhere you browse. ## Clean up Remove the synthetic headers from any browser tool when you finish, and keep the `development` condition around `microsoftIdentityPlatform()`. These checks prove your roles, tenancy, and authorization configuration. They do not test production token verification or your ingress. # Authenticate observable query transports > Authenticate WebSocket and SSE connections with default handlers or verified host sessions, and understand why observable queries cannot select named schemes. An observable connection needs credentials on its opening HTTP request, not inside a subscription message. The [Entra bearer](/arc/backend/typescript/identity/entra-bearer/) and [AuthProxy/EasyAuth](/arc/backend/typescript/identity/authproxy-easyauth/) recipes register **default** authentication handlers, which Arc uses for observable connections as well as ordinary HTTP requests. ## Do not select a named scheme on an observable query An observable query with `@authorize({ schemes: ['Entra'] })`, or the equivalent low-level `authorization.schemes`, is rejected during route-table construction. This applies even if you intended to use only its direct route. The multiplexed hub does not authenticate each subscription separately and cannot select different credentials for different queries on one connection. Instead, register the handler in `authentication: [handler]`, or use `nativePrincipal: true` with a host callback that has actually authenticated the request. These modes are mutually exclusive. Keep **roles and named policies** on the observable queries: each subscription still runs query authorization against the connection’s principal. A named policy is an authorization rule, not an authentication scheme. ## Put credentials where the transport can carry them | Transport | Supported authentication path | | --- | --- | | Native WebSocket client | Send `Authorization: Bearer ` on the HTTP upgrade request; a default `jwtBearer()` handler verifies it | | Browser WebSocket | The browser cannot set an arbitrary Authorization header. Use a real session cookie verified by a default application handler, a trusted native-principal callback, or an authentication proxy that forwards the verified identity on the upgrade | | Browser `EventSource` | Uses same-origin cookies, not custom Authorization headers. Use the same verified session/proxy boundary for the stream **and** control POSTs | | Fetch-based or native SSE client | Can send a Bearer header on the stream request and every control POST; use the default bearer handler | | Direct observable HTTP route | Its snapshot, long-poll and SSE requests use the normal HTTP authentication path; credentials are required on each request | Arc does not supply a general-purpose login/session-cookie verifier. Your application’s handler must verify the actual session before returning a principal. The unsigned `.cratis-identity` **display cookie never qualifies**. Cross-origin cookies also require deliberate browser credential, SameSite, CORS and CSRF configuration; prefer a same-origin boundary when possible. Do not put access tokens in query strings, query arguments, hub frames or WebSocket subprotocol names. Arc does not treat those fields as bearer credentials, and URLs commonly reach logs and browser history. Changing a generated client’s HTTP headers does not give a browser WebSocket or `EventSource` constructor a header API it does not have. ## Protect both the opening request and the controls * WebSocket hub: `/.cratis/queries/ws`. Default authentication runs on the upgrade. Invalid credentials reject the upgrade; an anonymous connection can still be admitted, but cannot subscribe to a protected query. * SSE hub: `GET /.cratis/queries/sse`, with `POST /.cratis/queries/sse/subscribe` and `/unsubscribe`. The stream captures the caller; every control request authenticates again and must match the connection’s tenant, authentication state and principal ID. A connection ID is not a credential. Unknown or unowned connections return 404. * Direct query routes: ordinary HTTP requests authenticate normally; WebSocket upgrades use the same default connection-authentication path as the hub. Query authorization still decides whether the requested query may execute. The standalone Node host handles upgrades. **Express HTTP middleware does not run on Node upgrades**: attach Arc’s WebSocket listener and authenticate using an Arc handler or a verified upgrade callback, not a session value you assume Express already populated. Fastify hooks and Hono middleware have different upgrade behavior; follow the [host-specific WebSocket guidance](/arc/backend/typescript/hosts/websockets/). Use HTTPS/WSS. Keep exact allowed Origins configured for browser connections; absent Origin is allowed for native clients and is not proof of identity. A proxy must forward upgrades, strip caller-supplied identity headers **before** authenticating, and block direct backend access just as for ordinary HTTP. Origin checks do not replace authentication, header sanitization, or CSRF defenses. ## Account for long-lived identity Authentication captures a principal when the connection opens. A new token sent on a later SSE control request proves ownership; it does not replace the connection’s captured principal or update its roles. Reconnect after sign-in, sign-out, token renewal or permission changes. `jwtBearer()` checks expiry when it authenticates the request. It does **not** schedule the live connection to close at token expiry, continuously refresh tokens, or detect provider revocation. For sensitive streams, implement an [observable emission guard](/arc/backend/typescript/queries/observable-query-emission-guards/) that checks current permission/session validity and denies further delivery, and define a server-enforced connection lifetime. A client-only reconnect timer is not a revocation control. These boundaries are implemented by `createRouteTable`, `prepareObservableUpgrade`, `resolveConnectionContext`, `ObservableQueryHub` and `ObservableQuerySession` in Arc.Core. They are not properties of a particular identity provider. ## Related * [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/) for hub framing, controls and limits * [WebSockets](/arc/backend/typescript/hosts/websockets/) for mounting upgrades and Origin configuration * [Authorization policies and schemes](/arc/backend/typescript/core/authorization/#select-an-authentication-scheme) # How identity details are served > Register an identity details provider, reach your own services from it, and understand every /.cratis/me answer, the cookie Arc sets, and where identity is cached. Once a provider exists, three questions follow quickly. How does Arc find it? What does the browser get back when something goes wrong? And if the cookie caches the identity, when does a change in your data reach the screen? This page follows one request through `/.cratis/me` and answers each. ## Follow one request ``` Identity details providerArc HTTP pipelineBrowserIdentity details providerArc HTTP pipelineBrowserGET /.cratis/meAuthenticate the requestResolve the tenantprovide(principal, context) in a new service scopedetails, or undefinedValidate details against the declared shape200 JSON and Set-Cookie .cratis-identity ``` Authentication and tenant resolution run exactly as they do for a command or query. An anonymous caller never reaches the provider. The provider runs inside the request’s execution context, so `currentContext()` returns the same principal and tenant it receives as arguments. ## Register the provider Choose one registration: * **A decorated class.** Mark it `@identityDetailsProvider()` and add it with `builder.add(...)` or let `builder.discover(...)` find it. Arc constructs a new instance for every request, so its constructor must take no arguments. * **An option object.** Pass `identityDetails: { detailsType, provide }` or `identityDetails: { schema, provide }` to `ArcApplication.createBuilder(...)`. The explicit option wins, and cannot be combined with a discovered provider. Two discovered providers without an explicit option fail at `build()`. The provider declares the shape of its details either as `detailsType`, a class with `@field` declarations, or as a Zod `schema`. Prefer `detailsType`: the [proxy generator](/arc/backend/typescript/proxy-generation/) turns it into a frontend class you can pass to `useIdentity`. ## Reach your own services Because the class has no constructor injection, resolve collaborators from the request’s service scope inside `provide`: Features/Identity/DirectoryDetails.ts ```typescript import { field } from '@cratis/fundamentals'; import { currentServices, identityDetailsProvider, type IdentityDetailsProvider, type Principal } from '@cratis/arc.core'; import { Directory } from './Directory.js'; export class ProfileDetails { @field(String) displayName!: string; } @identityDetailsProvider() export class DirectoryDetails implements IdentityDetailsProvider { readonly detailsType = ProfileDetails; async provide(principal: Principal): Promise { const directory = await currentServices().resolve(Directory); return { displayName: directory.displayNameFor(principal) }; } } ``` `Directory` is your own service, registered with `builder.services.addScoped(Directory, ...)`. Arc disposes the scope after the provider finishes, also when it denies or fails. To keep someone out of the application, return `undefined`. Arc answers 403 for that caller. This decision controls `/.cratis/me` only: commands and queries still need their own [authorization](/arc/backend/typescript/core/authorization/). ## Every answer from /.cratis/me | Situation | `GET /.cratis/me` | | --- | --- | | No provider configured | Not mapped | | Anonymous caller | 401 | | The authentication handler rejected the credential | 401 | | Tenant resolution fails: missing with `tenancy.required`, or not a member under `tenancy.membershipClaim` | 400 or 403 | | `provide` returns `undefined` | 403 | | `provide` throws or rejects, the details do not match the declared shape, or the encoded cookie would exceed 4096 bytes | Generic 500, no cookie | | Otherwise | 200 with the identity JSON and the cookie | Every answer carries `Cache-Control: no-store`. `GET /.cratis/identity-details/schema` returns the JSON Schema of `details`; see [Identity details schema](/arc/backend/typescript/introspection/identity-details-schema/). ## The cookie Arc sets A 200 answer sets `.cratis-identity=; Path=/; SameSite=Lax`, adding `Secure` when the trusted transport is HTTPS. The value is the same JSON as the response body, Base64-encoded. It is not `HttpOnly`, because the frontend reads it. The JSON response keeps Unicode. The cookie escapes non-ASCII characters before encoding, so the client’s `JSON.parse(atob(cookie))` recovers names and details, including emoji. Cookie bytes are not guaranteed to match .NET’s JSON escaping. Keep details small. A cookie over 4096 bytes fails the request instead of being truncated, and anything you put in details is readable by any script on the page. Leave out tokens, secrets, and personal data the UI does not show. ## Where identity is cached The server never reads `.cratis-identity`. Every call to `/.cratis/me` authenticates the request and runs your provider again. Caching happens in the browser. The published client’s identity provider reads the cookie first and only calls `/.cratis/me` when the cookie is missing or when you ask it to refresh. That has two consequences: * **Details can be stale.** When roles or details change on the server, the UI keeps showing the cached values until the frontend refreshes. See [refresh after a change](/arc/backend/typescript/identity/frontend/#refresh-after-a-change). * **A forged cookie changes only the display.** Anyone can edit the cookie in their own browser. Every command and query still authorizes against the principal authentication produced, so the edit can show a button but never run the operation behind it. Arc on .NET differs here. Its `/.cratis/me` endpoint accepts a nonempty identity cookie before it consults the provider, and it has an `IIdentityProvider` service with `ModifyDetails`. Arc for TypeScript has neither. To store a user preference, send a command and keep the value in your own storage; the next refresh returns it through the provider. [Identity contracts](/arc/backend/typescript/identity/contracts/#principal-first-cookie-never) compares the two trust models. ## Recap * Register one provider, as a decorated class or as the `identityDetails` option. * Resolve services inside `provide`, and return `undefined` to answer 403. * Treat the cookie as a display cache owned by the browser. Arc never trusts it. Next, [show identity in a React frontend](/arc/backend/typescript/identity/frontend/). # Identity across services > Decide where /.cratis/me lives when your system has one Arc service, several services behind a gateway, or a dedicated identity service, and keep authentication at every boundary. A single Node service is simple: it authenticates the caller, serves `/.cratis/me`, and runs every command. As the system grows into an orders service, a billing service, and a gateway in front of them, the question changes. Which service answers “who is this?”, and how does each service know the caller is real? Arc does not coordinate identity between services. Each Arc application authenticates its own requests and, if you give it a provider, serves its own `/.cratis/me`. The choice of topology is yours. The rules below keep it safe whichever one you pick. ## The rules that hold in every topology * **Every service verifies the caller itself.** Configure authentication in each Arc application, for example [`jwtBearer()`](/arc/backend/typescript/core/authentication/#verify-jwt-bearer-tokens) with the same issuer and audience everywhere, or a host-verified [native principal](/arc/backend/typescript/hosts/native-principal/). A request that passed the gateway is not proof on its own. * **Every service authorizes its own operations.** Roles and policies belong on the commands and queries of the service that owns them. * **The identity cookie never crosses a trust boundary as evidence.** `.cratis-identity` is display data the browser can edit. Never forward it to another service to say who the caller is. Forward the real credential, such as the bearer token. ## One service ``` Browser Arc service provider and operations ``` Put the identity details provider in the service. This is the default and needs nothing beyond [Identity](/arc/backend/typescript/identity/). ## Several services behind a gateway ``` /.cratis/me /api/orders/... /api/billing/... Browser Gateway Orders service provider and operations Billing service operations only ``` The frontend talks to one origin, so it expects one `/.cratis/me`. Give exactly one service the provider and route `/.cratis/me` to it. The other services register no provider, so they do not map that route at all. Each service still maps its own `/.cratis/commands`, `/.cratis/queries`, and observable query routes under `/.cratis`. Route those per service, or keep them internal, instead of sending the whole `/.cratis` prefix to one backend. When the details need data owned by another service, fetch it inside `provide` and pass along the caller’s real credential. Arc does not merge details from several services for you. ## A dedicated identity service ``` /.cratis/me /api/... Browser Gateway Identity service provider only Domain services operations only ``` When several teams need a say in what the UI shows about a user, a small Arc application can own only the provider. It authenticates like the rest, answers `/.cratis/me`, and has no commands of its own. Keep its details small: the cookie it sets must stay under 4096 bytes, or `/.cratis/me` fails. ## Which one to choose | Situation | Topology | | --- | --- | | One backend, or a frontend per backend | One service | | Several backends behind one origin, with details owned by one of them | Gateway, provider in the owning service | | Details assembled from several domains, or owned by a platform team | Dedicated identity service | Whatever you choose, test it end to end: an anonymous request, a forged `.cratis-identity` cookie, and a request that skips the gateway must all fail at the service that owns the operation. Next, [simulate a signed-in user locally](/arc/backend/typescript/identity/local-development/) to exercise these rules without a real identity provider. # Introspection > Ask a running Arc application which commands and queries it serves, with routes and input JSON Schema, from the access-controlled /.cratis introspection endpoints, and know what they expose. You open a service you did not write, or one you wrote six months ago, and need to know what it accepts. Reading every feature folder takes a while. A developer tool, a contract test, or an AI assistant helping you has the same problem, and often cannot read the source at all. So ask the running application. Arc describes itself at three endpoints, built from the same metadata that binds requests, so the answer is never out of date. | Endpoint | Returns | | --- | --- | | [`GET /.cratis/commands`](/arc/backend/typescript/introspection/commands/) | Every command, with its route and payload schema | | [`GET /.cratis/queries`](/arc/backend/typescript/introspection/queries/) | Every query, with its route, full name, and arguments schema | | [`GET /.cratis/identity-details/schema`](/arc/backend/typescript/introspection/identity-details-schema/) | The JSON Schema of the identity details `/.cratis/me` returns | ## Try it Start the Tasks sample and ask for its commands: ```bash curl http://127.0.0.1:3000/.cratis/commands ``` You get one entry for `RegisterTask`, with the route `/api/tasks/registration/register-task` and a JSON Schema that requires a UUID `id` and a string `title`. [Command introspection](/arc/backend/typescript/introspection/commands/) shows the full answer. The queries endpoint lists `allTasks`, `taskById`, and `observeAllTasks` the same way. Behind that answer there is no extra registry. Arc builds the list from the operations it compiled at startup, and the schemas from the `@field` declarations or Zod schemas that also validate incoming requests. Rename a field, restart, and the endpoint shows the new name. ## What it exposes, and to whom An allowed caller sees every operation, whether or not they may invoke it. Catalogs describe names, routes, and input shapes, never application data. They accept only GET; other methods on a mapped route answer 405 with `Allow: GET`. ## Production access Discovery is anonymous only in Development by default. Elsewhere, Arc runs the configured `authentication` handlers (or uses the host-verified `nativePrincipal`) and requires an authenticated principal. Anonymous callers receive 401; a caller missing the configured role receives 403. Denials have a JSON `{ error }` body. Discovery responses carry `Cache-Control: no-store`. The same policy covers `/.cratis/commands`, `/.cratis/queries`, `/.cratis/identity-details/schema`, `/.cratis/users`, `/.cratis/tenants`, and `/openapi.json` on Express, Fastify, Hono, and the fetch handler. Command/query invocation, the observable transports, and `/.cratis/me` keep their own access rules. | Option | Default and meaning | | --- | --- | | `introspection.enabled` | `true`: map catalogs and HTTP OpenAPI, subject to the access policy. `false`: leave them unmapped in every environment. Identity discovery is unchanged. | | `introspection.requireAuthentication` | Unset: environment default. `true`: require authentication even in Development. `false`: explicitly allow anonymous discovery everywhere. | | `introspection.roles` | Optional comma-separated roles; any one grants access. Roles are trimmed and case-sensitive. Setting roles implies authentication in every environment. Empty roles or combining roles with `requireAuthentication: false` fails startup. | | `environmentName` | Code-only discovery environment override, otherwise `DOTNET_ENVIRONMENT`, then `ASPNETCORE_ENVIRONMENT`, then `NODE_ENV`. Only `Development` (case-insensitive) selects anonymous discovery. Missing or unknown names are not Development. | The Node builder uses the environment supplied through `configuration.env` when present, including its appsettings environment-file selection. The code-only `environmentName` option overrides those environment variables for discovery; `Cratis:Arc:EnvironmentName` and `Cratis__Arc__EnvironmentName` are not supported configuration keys. It does not change which environment file is loaded or the exception-detail default. Exception exposure still follows the host environment variables (or the Node builder’s supplied `configuration.env`), unless `exposeExceptionDetails` is explicitly set. This keeps a discovery override from exposing exception details unexpectedly. Fetch runtimes without `process` must supply `environmentName: 'Development'` for local anonymous tooling. The `development` option only enables development providers; it does not select this environment or disable authentication. Outside Development, a host without default Arc authentication handlers or `nativePrincipal: true` leaves all six routes unmapped and logs one startup warning. Named `authenticationSchemes` alone are not default handlers. Explicitly setting `requireAuthentication: true` or roles without a way to authenticate instead fails startup. Arc sends startup warnings through `logger` with an empty correlation ID, or `console.warn` when no logger is supplied or it fails. The Node configuration keys are `Cratis:Arc:Introspection:RequireAuthentication` and `Cratis:Arc:Introspection:Roles`, for example `Cratis__Arc__Introspection__RequireAuthentication=false`. Code options override corresponding configuration fields. An anonymous opt-out outside Development logs a startup warning too. For build-time tools fetching descriptions, run the local host in Development or supply valid credentials. Source-based proxy generation and the in-process `server.openApi()` API do not make HTTP requests and are unaffected. See [Migrating to secure discovery defaults](/arc/backend/typescript/upgrading/secure-defaults/) before upgrading a deployed anonymous consumer. ## Turn discovery off If deployed tools do not need command/query catalogs or HTTP OpenAPI, set `Cratis:Arc:Introspection:Enabled` to `false`. Arc leaves `/.cratis/commands`, `/.cratis/queries`, and `/openapi.json` unmapped on Express, Fastify, Hono, and fetch. Requests fall through to the host, normally returning 404, even for authenticated callers. To disable those endpoints everywhere, put this in `appsettings.json`: ```json { "Cratis": { "Arc": { "Introspection": { "Enabled": false } } } } ``` To keep local discovery but turn it off only in deployed environments, put the same block in `appsettings.Production.json` instead and run the deployment with `DOTNET_ENVIRONMENT=Production`. Alternatively, set this environment variable **in the deployment**, not in your local environment: ```bash Cratis__Arc__Introspection__Enabled=false ``` Or pass the same switch programmatically, overriding file and environment configuration: ```typescript import { ArcApplication } from '@cratis/arc.core'; const builder = ArcApplication.createBuilder({ introspection: { enabled: false } }); const app = await builder.build(); ``` The same `introspection: { enabled: false }` option works with the Node or fetch `ArcApplicationBuilder` constructor, the fetch `ArcApplication.createBuilder()`, `CratisApplication.createBuilder()`, and `new ArcServer(options)`. There is no separate fluent discovery setting or general `configure` callback. `runArc(server)` and `createArcNodeHandler(server)` use the options of the supplied server; Express, Fastify, and Hono helpers use the supplied server or built application. Their transport options do not accept another `introspection` override. The default is `true`. Node configuration accepts boolean values (and case-insensitive `true`/`false` strings); invalid values fail setup. Defined code values take precedence per field: `introspection: { enabled: false }` overrides file and environment values. An `undefined` code value for `enabled`, `requireAuthentication`, or `roles` preserves the configured value. Fetch builders and `new ArcServer(...)` take that code option directly; they do not bind configuration files or `Cratis__...` variables. This switch does not disable command/query execution, observable transports, `/.cratis/me`, or identity discovery. `/.cratis/identity-details/schema`, `/.cratis/users`, and `/.cratis/tenants` retain the access policy above; user/tenant data still requires opt-in development providers. In-process `server.openApi()`, `exportClientManifest(server)`, and source-based proxy generation keep working. Like Arc for .NET’s ASP.NET Core and Arc.Core hosts, disabled catalogs can be combined with `requireAuthentication: true` or roles **when authentication is configured**. Without authentication, explicit requirements still fail startup for identity discovery; the default non-Development missing-authentication warning also remains. Disabling catalogs does not excuse invalid roles or roles combined with `requireAuthentication: false`. The anonymous opt-out warning remains too. .NET skips the catalog mapper’s authentication check when disabled, but its identity mapper still resolves the policy. TypeScript also disables `/openapi.json` deliberately: it belongs to the same HTTP discovery group as the catalogs. This extends .NET’s catalog switch; .NET does not serve that route through its catalog mapper. It does not affect the in-process OpenAPI API. ## How it relates to OpenAPI and proxies Introspection, [`/openapi.json`](/arc/backend/typescript/open-api/), and the [proxy generator](/arc/backend/typescript/proxy-generation/) all see the same schemas. They serve different readers: | Use | For | | --- | --- | | Introspection | Arc-aware tools that need Arc’s own names: the fully qualified query name for hub subscriptions, or the `/validate` route of a command | | OpenAPI | General HTTP tooling: API clients, gateways, and code generators for other languages | | Proxy generator | Your TypeScript frontend, generated from source at build time without a running server | [How types appear in the document](/arc/backend/typescript/open-api/schemas/) explains how concepts, enums, and optional fields are rendered, and applies to the introspection schemas too. [Concepts in the document](/arc/backend/typescript/open-api/concepts/) and [Enums in the document](/arc/backend/typescript/open-api/enums/) go into detail. ## Related * [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) * [HTTP contract reference](/arc/backend/typescript/reference/http-contract/) # Command introspection > The shape of GET /.cratis/commands, with one entry per registered command and its payload JSON Schema. A tool that fills in a command form, or a test that checks nobody removed a command, needs the command’s route and the exact shape of its body. `GET /.cratis/commands` returns a JSON array with one entry per registered command. For the Tasks sample, which registers its generated metadata: ```json [{ "name": "RegisterTask", "namespace": "Tasks.Registration", "route": "/api/tasks/registration/register-task", "type": "RegisterTask", "documentationSummary": "Register a task.", "payloadSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "id": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "format": "uuid" }, "title": { "type": "string" } }, "required": ["id", "title"] } }] ``` | Field | Meaning | | --- | --- | | `name` | The command name | | `namespace` | The namespace, or `""` | | `route` | The execution route; the validation route is this plus `/validate` | | `type` | The command name | | `documentationSummary` | A low-level definition’s `summary`, or the JSDoc summary of a model-bound command when [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) is registered; otherwise `""` | | `payloadSchema` | JSON Schema 2020-12 of the request body | A concept field appears as its underlying scalar, here a UUID string. Optional and defaulted fields are left out of `required`, a nullable field is `anyOf` its type and `null`, and an `@enumeration` field is `anyOf` one `const` per enum value. [Concepts in the document](/arc/backend/typescript/open-api/concepts/), [Enums in the document](/arc/backend/typescript/open-api/enums/), and [How types appear in the document](/arc/backend/typescript/open-api/schemas/) cover each case; the same schema is the command’s OpenAPI request body. ## Related * [Introspection](/arc/backend/typescript/introspection/) * [Query introspection](/arc/backend/typescript/introspection/queries/) # Identity details schema > The shape of GET /.cratis/identity-details/schema, which describes the application details returned by /.cratis/me. A frontend or tool that shows identity details from another language cannot import your `detailsType` class. It can read its schema instead. `GET /.cratis/identity-details/schema` returns the JSON Schema of the `details` object that [`/.cratis/me`](/arc/backend/typescript/identity/) returns. The schema comes from the identity details provider: | Configuration | Response | | --- | --- | | A provider with `detailsType` | JSON Schema derived from the class’s `@field` declarations | | A provider with a Zod `schema` | That schema converted to JSON Schema | | No provider, legacy `identityDetailsSchema` option | That object, unchanged | | Neither | `{}` | For a `detailsType` with one `@field(String) greeting` field: ```json {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"],"additionalProperties":false} ``` The endpoint follows the shared [discovery access policy](/arc/backend/typescript/introspection/#production-access): anonymous in Development, authenticated elsewhere by default. It describes the shape only, never a user’s data. The legacy `identityDetailsSchema` option cannot be combined with `identityDetails`. ## Related * [Identity](/arc/backend/typescript/identity/) * [Introspection](/arc/backend/typescript/introspection/) # Query introspection > The shape of GET /.cratis/queries, with one entry per registered query, its full name, and its arguments JSON Schema. A developer tool that subscribes to an observable query needs its fully qualified name, and a client that calls a query needs its arguments. `GET /.cratis/queries` returns both, in a JSON array with one entry per registered query, observable queries included. For the Tasks sample’s `taskById`: ```json { "name": "taskById", "namespace": "Tasks.Listing.TaskItem", "route": "/api/tasks/listing/task-by-id", "type": "taskById", "documentationSummary": "", "fullyQualifiedName": "Tasks.Listing.TaskItem.taskById", "argumentsSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "id": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "format": "uuid" } }, "required": ["id"] } } ``` | Field | Meaning | | --- | --- | | `name` | The query name; for a model-bound query, the method name | | `namespace` | For a model-bound query, the discovery namespace plus the read-model class | | `route` | The GET route | | `type` | The query name | | `documentationSummary` | A low-level definition’s `summary`, or the JSDoc summary of a model-bound query method when generated artifact metadata is registered; otherwise `""`. The Tasks sample’s query methods have no JSDoc | | `fullyQualifiedName` | The identity used by direct calls and hub subscriptions, such as `Tasks.Listing.TaskItem.taskById` | | `argumentsSchema` | JSON Schema 2020-12 of the arguments; `{ "properties": {} }` when there are none | Reserved paging and sorting parameters are not part of `argumentsSchema`. ## Related * [Introspection](/arc/backend/typescript/introspection/) * [Command introspection](/arc/backend/typescript/introspection/commands/) # MongoDB > Serve model-bound queries from tenant-scoped MongoDB collections with BSON mapping, database-side paging, change-stream observation, and command read models. Your read models live in MongoDB, and every tenant has its own database. Wiring a client, choosing the database per request, mapping concepts and GUIDs to BSON, and turning a change stream into a live query is the same code in every service. `@cratis/arc.mongodb` supplies it: your model declares its fields once, and the collection maps them to BSON and returns instances of your model. Source preview `@cratis/arc.mongodb` is optional and not published to npm. It uses the MongoDB 6 driver. The [capability reference](/arc/backend/typescript/reference/capabilities/#persistence-and-chronicle) has its status and the checks behind it. ## What it provides | Capability | Page | | --- | --- | | Register collections with `builder.withMongoDB(...)`, or from `Cratis:MongoDB` configuration, and inject them into queries | [Get started](/arc/backend/typescript/mongodb/getting-started/) | | Choose a database, or a server, per tenant | [Tenancy](/arc/backend/typescript/mongodb/tenancy/) | | Map decorated fields, concepts, GUIDs, and dates to BSON | [Serializers](/arc/backend/typescript/mongodb/serializers/) | | Match Arc on .NET’s property and collection naming | [Naming policies](/arc/backend/typescript/mongodb/naming-policies/) | | Count, sort, and page in the database | [Paging](/arc/backend/typescript/mongodb/paging/) | | Turn a change stream into an observable query | [Observing collections](/arc/backend/typescript/mongodb/observing-collections/) | | Combine two or three live collections | [Joined observation](/arc/backend/typescript/mongodb/joined-observe/) | | React to raw collection changes | [Change-stream watcher](/arc/backend/typescript/mongodb/change-stream-watcher/) | | Store GeoJSON Point, LineString, and Polygon fields | [Geospatial types](/arc/backend/typescript/mongodb/geospatial/) | | Load a read model by command key | [Command context](/arc/backend/typescript/commands/command-context/#load-a-read-model-by-key) | `withMongoDB` registers a read-model resolver for the models you list in `readModels`, so a command can declare `@inject(commandReadModel(TaskRecord))` and receive the document whose identity equals the command key. Do not also register another integration, such as Chronicle, as the owner of the same type; `build()` fails when two claim one type. Storage does not authorize a caller Arc selects a tenant from the execution context, and the collection selects that tenant’s database. Your authentication and authorization still have to verify that the caller may use that tenant and read those documents. Never pass untrusted request JSON directly to a MongoDB filter. ## Low-level helper The original `MongoReadModels` remains for low-level `defineQuery` users. It takes a caller-owned client, `databaseForTenant`, and a trusted `filterFor(input, context)`. Its `queryPage` accepts Arc sorting only for fields listed in `sortableFields`, and caps pages at 100 by default. It has no change streams or field codecs; use the model-bound collection for those. `MongoReadModels` returns raw driver documents. When they hold a protected Chronicle read model, set `readModel` to that class so Arc can release them. The class must also be registered with `withChronicle`; otherwise Arc has no release interceptor for it and serves the documents as stored. See [Release raw MongoDB documents](/arc/backend/typescript/chronicle/compliance/#release-raw-mongodb-documents). ## Current boundaries This integration does not supply cross-store transactions or a durable change-stream checkpoint. The watcher shares a stream **within a tenant scope**, not across the process. Recognized transient reads retry at most twice; writes are not retried. Arc-owned clients expose OpenTelemetry MongoDB metrics, but caller-owned clients are not instrumented. Do not infer .NET’s process-wide watcher or general-purpose resilience interceptors from these narrower guarantees. The [capability reference](/arc/backend/typescript/reference/capabilities/#persistence-and-chronicle) has the parity details. Start with [Get started](/arc/backend/typescript/mongodb/getting-started/). # Watch MongoDB changes across collections > React to tenant-scoped database changes with a shared stream and bounded observation lifetimes. When several parts of your application need to respond to MongoDB changes, opening a separate change stream for each one is wasteful. Resolve `mongoDBWatcher` in the same Arc scope as your collections. The watcher shares one **database-level** change stream among subscriptions in that scope, and routes collection changes to each subscriber. This is a source-preview API. It is not a durable event consumer or a replacement for Chronicle. ## React to changes The example assumes a configured Arc application, an active tenant scope, and registered `Book` and `Author` models: ```typescript import { mongoCollection, mongoDBWatcher } from '@cratis/arc.mongodb'; const books = await scope.resolve(mongoCollection(Book)); const watcher = await scope.resolve(mongoDBWatcher); const subscription = watcher.changes(books).subscribe(change => { console.log(change.operationType, change.documentKey); }); // On shutdown or when this work ends: subscription.unsubscribe(); await scope.dispose(); ``` `changes` returns an RxJS `Observable>`. Updates use MongoDB’s `updateLookup` full-document option; deletes have a document key but no full document. Do not persist a resume token from this API or use it as an exactly-once feed. Arc does not release `fullDocument`: for a protected Chronicle read model it holds the stored ciphertext. See [What Arc does not release](/arc/backend/typescript/chronicle/compliance/#what-arc-does-not-release). ## Scope and failure MongoDB change streams require a replica set or sharded cluster and database-level watch permissions. A standalone server fails before the initial read. Join only collections resolved from the **same tenant and Arc scope** as the watcher; a cross-scope or cross-tenant collection is rejected. Disposing the scope, aborting its signal, or unsubscribing the last listener closes the cursor. A later subscription opens a new stream and starts at a new operation time. The driver resumes errors it recognizes as resumable. Other errors terminate all subscribers with `error`; the watcher does **not** silently reconnect and skip an unknown interval. Resubscribe with a fresh scope and re-read authoritative state if you need recovery. The watcher holds no durable checkpoint. For a live combined result, use [joined observation](/arc/backend/typescript/mongodb/joined-observe/). For a single collection’s complete snapshots, [observe on the collection](/arc/backend/typescript/mongodb/observing-collections/) instead. # Store GeoJSON with Fundamentals types > Encode Point, LineString, and Polygon fields as MongoDB GeoJSON and restore typed geometry. Use the geospatial types from `@cratis/fundamentals/geospatial` when a MongoDB read model stores locations, routes, or areas. `MongoDocumentCodec` writes their geometry as GeoJSON without changing driver-wide BSON serialization. ## Point: one location Declare `@field(Point)` on the model. Longitude comes **first**, then latitude: ```typescript import { field } from '@cratis/fundamentals'; import { Point } from '@cratis/fundamentals/geospatial'; import { key } from '@cratis/arc.core'; class Place { @field(String) @key() id!: string; @field(Point) location!: Point; } const place = Object.assign(new Place(), { id: 'one', location: new Point(10, 20) }); // collection.codec.serialize(place).location -> { type: 'Point', coordinates: [10, 20] } ``` MongoDB can index the stored `location` field with a `2dsphere` index. Creating indexes and composing `$near` filters are application responsibilities; the Fundamentals `Point` is **not** a driver `GeoJSON` filter-builder argument. ## LineString: a route A `LineString` holds two or more `Point`s. The codec writes `{ type: 'LineString', coordinates: [[10, 20], [30, 40]] }` for: ```typescript import { LineString, Point } from '@cratis/fundamentals/geospatial'; const route = new LineString([new Point(10, 20), new Point(30, 40)]); ``` Declare the field with `@field(LineString)`. Typed reads restore `LineString` and `Point` instances, not plain arrays. ## Polygon: an area, optionally with holes A polygon stores the outer shell first and then its interior rings. Each `LinearRing` needs at least four points; repeat the first point as the last: ```typescript import { LinearRing, Point, Polygon } from '@cratis/fundamentals/geospatial'; const shell = new LinearRing([ new Point(10, 20), new Point(30, 20), new Point(30, 40), new Point(10, 20) ]); const area = new Polygon(shell); ``` Declare `@field(Polygon)` on the model. The BSON field contains `{ type: 'Polygon', coordinates: [/* shell coordinates, then holes */] }`; reads reconstruct `Polygon` and its `LinearRing`s. The codec rejects an unclosed ring, non-finite coordinates, a mismatched GeoJSON type, or missing coordinates rather than returning a misleading geometry. It does not enforce winding order, non-intersection, or MongoDB’s full spatial-index rules. Validate domain geometry before writing, and use stored MongoDB field names in your own spatial filters. See [serializers](/arc/backend/typescript/mongodb/serializers/) for the other model types and [naming policies](/arc/backend/typescript/mongodb/naming-policies/) for stored names. # Get started with MongoDB > Register a MongoDB client and read models with withMongoDB, inject a tenant-scoped collection into model-bound queries, configure the connection from appsettings.json, and own the client's lifetime. This page connects one read model to MongoDB and serves it from three queries: a list, a page, and a live list. It follows the package’s [replica-set integration fixture](https://github.com/Cratis/Arc.TypeScript/tree/main/Source/MongoDB/for_MongoCollection/given). You need a MongoDB server; the live query needs a replica set. ## Declare the model TaskRecord.ts ```typescript import { field, Guid } from '@cratis/fundamentals'; import { key } from '@cratis/arc.core'; export class TaskRecord { @field(Guid) @key() id!: Guid; @field(String) title!: string; } ``` The collection reads and writes only the fields you declare with `@field`. `@key()` marks the field stored as `_id`; without it, a field named `id` is used, and a model with neither is rejected. ## Inject the collection into queries TaskQueries.ts ```typescript import { query, queryOptions, readModel, service, type QueryOptions } from '@cratis/arc.core'; import { mongoCollection, type MongoCollection } from '@cratis/arc.mongodb'; import { TaskRecord } from './TaskRecord.js'; const tasks = mongoCollection(TaskRecord); @readModel() export class TaskQueries { @query(service(tasks)) static async all(items: MongoCollection): Promise { return items.find(); } @query(service(tasks), queryOptions()) static async page(items: MongoCollection, options: QueryOptions) { return items.queryPage({}, options); } @query({ observable: true }, service(tasks)) static changes(items: MongoCollection) { return items.observe(); } } ``` `mongoCollection(TaskRecord)` is a service token. Each request resolves it to the collection in the current tenant’s database, so a query never chooses a database itself. `all` returns every task, `page` pushes count, sort, and paging into MongoDB (see [Paging](/arc/backend/typescript/mongodb/paging/)), and `changes` opens a change stream (see [Observing collections](/arc/backend/typescript/mongodb/observing-collections/)). For an owner-restricted query, build the filter from the verified principal, such as `items.find({ owner: principal.id })`, instead of forwarding a caller-provided object. ## Register MongoDB main.ts ```typescript import { ArcApplication, TenantResolverType } from '@cratis/arc.core'; import { MongoClient } from 'mongodb'; import '@cratis/arc.mongodb'; import { TaskRecord } from './TaskRecord.js'; import { TaskQueries } from './TaskQueries.js'; const client = new MongoClient(process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017'); const builder = ArcApplication.createBuilder({ tenancy: { resolverType: TenantResolverType.Fixed, fixedTenantId: 'default' } }); builder.add(TaskQueries).withMongoDB({ client, databaseNameResolver: tenant => `tasks_${tenant}`, readModels: [TaskRecord] }); const app = await builder.build(); await app.run(); await client.close(); ``` Importing `@cratis/arc.mongodb` adds `withMongoDB` to the builder. The exported `withMongoDB(builder, options)` function is equivalent. List every model you inject in `readModels`; a model left out has no collection token. The fixed `default` tenant makes this a single-tenant example, and every request uses the `tasks_default` database. See [Tenancy](/arc/backend/typescript/mongodb/tenancy/) for real tenant routing. A GET on the `all` query’s route answers with the stored tasks. [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) explains how routes are derived. ## Configure the connection in appsettings.json `withMongoDB` reads `Cratis:MongoDB` from the application’s [configuration](/arc/backend/typescript/configuration/), so the server address and database can stay out of code: appsettings.json ```json { "Cratis": { "MongoDB": { "server": "mongodb://127.0.0.1:27017", "database": "tasks" } } } ``` main.ts (excerpt) ```typescript builder.add(TaskQueries).withMongoDB({ readModels: [TaskRecord] }); ``` Only `server` and `database` bind from configuration; set everything else in code. `Cratis__MongoDB__Server` and `Cratis__MongoDB__Database` override the file in a deployment. Values in code win over configuration, and a `client`, `server`, or `serverResolver` in code replaces a configured `server`. With `server`, Arc creates the client and closes it when the application is disposed. ## Options | Option | Meaning | | --- | --- | | `client` | A caller-owned `MongoClient`; Arc leaves it open | | `server` | A MongoDB URI; Arc creates the client and closes it with the application | | `serverResolver(tenantId, context)` | A URI per tenant, for tenants on different servers | | `database` | The default database name; other tenants use `+` | | `databaseNameResolver(tenantId, context)` | A database name per tenant | | `readModels` | Model classes registered for injection and command read-model resolution | | `namingPolicy`, `collectionName`, `ignoreConventions` | See [Naming policies](/arc/backend/typescript/mongodb/naming-policies/) and [Serializers](/arc/backend/typescript/mongodb/serializers/) | | `maxPageSize` | Page size cap, 100 by default, at most 10,000 | | `maxObservableItems` | Observed snapshot cap, 1,000 by default, at most 10,000 | Specify exactly one of `client`, `server`, or `serverResolver`, and either `database` or `databaseNameResolver`. A missing tenant or empty database name fails the request rather than reading an implicit default database. ## Write documents The collection’s query methods read. To write from a command, inject the same token and write through the driver collection, encoding the model first: ```typescript await items.native.insertOne(items.codec.serialize(task)); ``` Encoding through `codec` keeps the stored document in the shape reads expect. See [Serializers](/arc/backend/typescript/mongodb/serializers/#write-through-the-driver). ## Related * [Serializers](/arc/backend/typescript/mongodb/serializers/) * [Paging](/arc/backend/typescript/mongodb/paging/) * [Observing collections](/arc/backend/typescript/mongodb/observing-collections/) # Observe joined MongoDB collections > Combine current tenant-scoped collections and re-emit the result when either changes. A catalog can depend on books **and** their authors. Observing only books leaves the catalog stale when an author changes. Resolve the scoped watcher, join the two collections, and select the result you want to publish: ```typescript import { mongoCollection, mongoDBWatcher } from '@cratis/arc.mongodb'; const books = await scope.resolve(mongoCollection(Book)); const authors = await scope.resolve(mongoCollection(Author)); const watcher = await scope.resolve(mongoDBWatcher); const catalog = watcher.observe(books, { published: true }) .join(authors, { active: true }) .select((publishedBooks, activeAuthors) => ({ publishedBooks, activeAuthors })); const subscription = catalog.subscribe(value => console.log(value)); // When the consumer is done: subscription.unsubscribe(); ``` The example is a service fragment: register both models with `withMongoDB({ readModels: [Book, Author], ... })`, and resolve the scope under a trusted tenant identity. The filters are **MongoDB filters in stored field names**, not predicates; use trusted values. See [naming policies](/arc/backend/typescript/mongodb/naming-policies/#names-in-your-own-filters). `select` returns an RxJS `Observable`, rather than .NET’s `ISubject`. It first emits a combined snapshot, then recomputes it after a change to either collection. Call `.join(thirdCollection, filter?)` before `select` to combine three collections. Every change in a watched collection triggers a refetch, even when the changed document does not match a filter. Consecutive changes while a read is in progress are coalesced into another complete snapshot, not buffered without a bound. Do not treat emissions as an audit trail or a transactionally consistent view across collections. If a joined collection holds a Chronicle read model with personal or encrypted data that is registered with `withChronicle`, what happens depends on the selected shape. Decoded instances placed unmapped inside another object, as in the example, fail the query, because Arc does not release instances nested in the selected object. A selector that returns an array of the instances themselves has each one released. Instances the selector maps to DTOs or copies are served as stored, as ciphertext; release those yourself with the tenant store’s `readModels.release`. See [What Arc does not release](/arc/backend/typescript/chronicle/compliance/#what-arc-does-not-release). The per-collection `maxObservableItems` cap applies to **each** joined snapshot. If a read, selector, or change stream fails, the observable errors instead of sending a partial list. The watcher shares one database-level stream per scope; subscriptions close on unsubscribe or scope disposal. Never keep a tenant-scoped collection in a singleton. See [change-stream watcher](/arc/backend/typescript/mongodb/change-stream-watcher/) for recovery limits and [single-collection observation](/arc/backend/typescript/mongodb/observing-collections/) when joins are unnecessary. # Naming policies > Choose how MongoDB property and collection names are derived, match Arc on .NET when both read one database, write a custom policy, and use the stored names in your own filters. A .NET service writes `Tasks` documents with a `Title` property. Your TypeScript service reads the same database, and its model declares `title`. Unless both sides agree on names, the TypeScript side reads documents with every field missing, and nothing fails. The naming policy decides the stored name of every property and the name of every collection. ## Choose a preset | Policy | Properties | Collections | .NET equivalent | | --- | --- | --- | --- | | `defaultMongoNamingPolicy` (default) | As declared | Pluralized read-model class name, case kept | Unconfigured MongoDB builder (`new DefaultNamingPolicy()`) | | `camelCaseMongoNamingPolicy` | Camel-cased | Pluralized, camel-cased | `.WithCamelCaseNamingPolicy()` | For example, a `TaskRecord` class with `Id` and `Title` fields: | | Default | Camel case | | --- | --- | --- | | Collection | `TaskRecords` | `taskRecords` | | `Title` field | `Title` | `title` | | `URL` field | `URL` | `URL` | | `@key()` field | `_id` | `_id` | Both presets keep a leading acronym together, as .NET does: `URL` stays `URL` under camel case. With the default policy, the stored name is exactly the TypeScript field name. To read documents a default-policy .NET service wrote, spell the field as .NET does, `Title`, not `title`. When the .NET side uses camel case, use `camelCaseMongoNamingPolicy` and keep idiomatic TypeScript field names. ## Set a policy ```typescript import { camelCaseMongoNamingPolicy } from '@cratis/arc.mongodb'; builder.withMongoDB({ client, database: 'tasks', readModels: [TaskRecord], namingPolicy: camelCaseMongoNamingPolicy }); ``` The policy applies to every model registered in that `withMongoDB` call, including nested models and derived types. ## Pluralization Both presets pluralize the class name with a few English rules: a name ending in `s`, `x`, `z`, `ch`, or `sh` takes `es`, a consonant followed by `y` becomes `ies`, and everything else takes `s`. So `Category` becomes `Categories` and `Box` becomes `Boxes`. .NET pluralizes with Humanizer, which knows irregular words: `Person` becomes `People` there, but `Persons` here. When the two disagree, set the name yourself. ## Override the collection name To change only the collection, keep the policy and pass `collectionName`: ```typescript builder.withMongoDB({ client, database: 'library', readModels: [Person], collectionName: model => model === Person ? 'People' : `${model.name}s` }); ``` The function receives each registered model class and must return a nonempty name for all of them, or the request that resolves the collection fails with `MongoDB collection name is required`. ## Chronicle-projected read models When Chronicle projects a read model into MongoDB, the collection name has to match the one Arc reads. If the Chronicle client is created by [`withChronicle`](/arc/backend/typescript/chronicle/registration-options/) with a `connectionString` and the application also calls `withMongoDB`, Arc gives that client a naming policy that applies this page’s rule to a read model class: `collectionName?.(model) ?? namingPolicy.collectionName(model)`. A projected `User` lands in `Users` under the default policy and in `users` under `camelCaseMongoNamingPolicy`, with no extra setup. The same collection name is used whether `withMongoDB` is called before or after `withChronicle`. The rule covers only the classes listed in `readModels`, because those are the only classes Arc reads from MongoDB: `mongoCollection(...)` and command read-model resolution both work from that list. A read model class that is not in `readModels`, such as a Chronicle-only read model served through `ChronicleReadModels`, keeps Chronicle’s default collection name, its identifier. So does a read model Chronicle knows only by identifier, such as a projection with a custom `.containerName(...)`. The policy changes only the collection name. The database must still match: Chronicle writes the default namespace’s read models to a database named after the event store, and to `+` for any other namespace. `withMongoDB` uses `` and `+`, so set `database` to the event store name. Two cases are not wired automatically: * **You pass your own Chronicle `client`.** Arc never changes a client it does not own. Create it with `ChronicleOptions.fromConnectionString(connectionString, { readModelNamingPolicy })` and return the name you want, for example `(identifier, readModelType) => readModelType ? resolveMongoCollectionName(mongoOptions, readModelType) : identifier`. `resolveMongoCollectionName`, exported from `@cratis/arc.mongodb`, is the function Arc itself uses, and `mongoOptions` is an object with the same `namingPolicy` and `collectionName` you pass to `withMongoDB`. Passing `readModelNamingPolicy` to `withChronicle` together with a `client` throws. * **You pass `readModelNamingPolicy` to `withChronicle`.** Your policy replaces the automatic one. Without `withMongoDB`, Arc sets no policy and Chronicle names the collection after the read model identifier. The automatic policy needs `@cratis/chronicle` 6.29.0 or later, which is the lowest version `@cratis/arc.chronicle` accepts. Upgrading Before this behavior, a projected read model was stored in a collection named after its identifier, such as `User`, which the default `withMongoDB` policy never read. A workaround was `collectionName: model => model.name`. That workaround still gives `User`, so an application that uses it keeps its collection. An application on the default policy now has Chronicle register `Users` instead. Replay the projection after upgrading so the new collection is populated from the event log. The previous collection is left as it is, so drop it yourself when you no longer need it. This is guidance from reading the Chronicle kernel’s registration code (`ReadModelsManager.Register` and the projection definition comparer on `main` of Cratis/Chronicle, release 19.22.1): a registered read model definition, including its container name, replaces the stored one, the kernel writes and reads through the new name, and it moves no data. Confirm it against your own kernel version before relying on it. A `collectionName` override that throws, or returns an empty name, for a class in `readModels` now fails when the Arc-created Chronicle client registers its read models, not only when a request resolves the collection. ## Write a custom policy A `MongoNamingPolicy` is two functions: ```typescript import type { MongoNamingPolicy } from '@cratis/arc.mongodb'; export const snakeCase: MongoNamingPolicy = { propertyName: name => name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(), collectionName: type => type.name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase() }; ``` Write one when a .NET service uses a customized policy, or when your database already has its own convention. `propertyName` is never called for the key: the `@key()` field is always stored as `_id`. ## Names in your own filters `find`, `queryPage`, and `observe` take a raw MongoDB filter. The filter goes to the driver as written, so it must use **stored** names and **stored** values. For a model with a `status` field and a `Guid` key: ```typescript import type { Document, Filter } from 'mongodb'; const done = await items.find({ [items.codec.fieldName('status')]: 'done' }); const one = await items.find({ _id: items.codec.id(taskId) } as Filter); ``` `codec.fieldName(name)` converts a model field name to its stored name under the active policy, accepts the wire name too, and throws for a name the model does not declare. `codec.id(value)` encodes a key value, such as a `Guid`, the way it is stored. A `Guid` in any other field is stored as UUID binary, so a plain string will not match it. ## Sorting and errors Client sorting goes through the same mapping. A request with `sortBy=title` resolves `title` to the declared field and then to its stored name. A name that is not a declared field, such as `secret` or `$where`, answers 400 before MongoDB is queried. See [Paging](/arc/backend/typescript/mongodb/paging/). | Mistake | What happens | | --- | --- | | Field spelled differently from the stored documents | Reads succeed, and the field stays unset on every instance | | Policy differs from the service that wrote the documents | Same: reads succeed with missing values | | Collection name differs | Reads return nothing, and writes go to a new collection | | Unknown sort field | 400 | The first three produce no error. When a query returns empty or half-filled models, compare the stored names with `codec.fieldName(...)` and the collection name first. ## Related * [Serializers](/arc/backend/typescript/mongodb/serializers/) * [Get started with MongoDB](/arc/backend/typescript/mongodb/getting-started/) # Observing collections > Turn a tenant's MongoDB collection into an observable query with change streams, page and sort it, handle deleted documents, and know what happens when the stream fails. A task list backed by MongoDB should update when a document changes, whichever process wrote it: this service, another service, or a script. Polling is either slow or wasteful. `observe` opens a MongoDB change stream on the tenant’s collection and turns it into an Arc observable source, so the client receives a new list whenever the collection changes. ```typescript import type { Observable } from 'rxjs'; import { query, readModel, service } from '@cratis/arc.core'; import { mongoCollection, type MongoCollection } from '@cratis/arc.mongodb'; import { TaskRecord } from './TaskRecord.js'; const tasks = mongoCollection(TaskRecord); @readModel() export class TaskChanges { @query({ observable: true }, service(tasks)) static all(items: MongoCollection): Observable { return items.observe(); } } ``` A GET on this query answers 200 with the current tasks. Server-sent events and WebSocket subscribers receive the full list now, and again after every change. Change streams need a replica set or a sharded cluster. ## Choose what to observe | Method | Emits | | --- | --- | | `observe(filter?)` | Every document matching the filter, as a full list | | `observeById(id)` | One document, or `null` while it does not exist | | `observeIterable(filter?)` | The same lists as `observe`, as an async iterable, without RxJS | | `observeByIdIterable(id)` | The same values as `observeById`, as an async iterable | The filter is a raw MongoDB filter in stored names; see [Names in your own filters](/arc/backend/typescript/mongodb/naming-policies/#names-in-your-own-filters). Build it from trusted values, never from request JSON. ## How observation works 1. The change stream opens **before** the first snapshot is read, from a server operation time captured just before, so a change between the two is not lost. 2. The first snapshot is the source’s current value, so a plain GET answers 200 without waiting. 3. After each change, and after any changes already queued behind it, the whole query is read again. A burst of changes becomes one new list. 4. For `observe`, every change on the collection triggers a new read, even one that does not affect the filter, so the list you receive is always complete. `observeById` reacts only to changes of its own document. Each subscription opens one change stream on its first read. Unsubscribing, closing the connection, or disposing the Arc scope closes the cursor. The async iterables also close when the loop exits. ## When a document is gone * `observe` drops a deleted document from the next list. Deleting the last matching document emits an empty list. * `observeById` emits `null` when the document is deleted, and emits the document again if it is recreated with the same ID. * A document that is updated so it no longer matches the filter drops out of the next list, like a deletion. A client should treat `null` and an empty list as ordinary states, not as errors. ## Page and sort an observed query A client can page and sort an observable query with the same parameters as a snapshot query. Arc applies them to **each** emitted list in memory, with the same options for the whole subscription: ```text GET /api/.../all?pageSize=10&page=0&sortBy=title ``` Each emission then holds the first ten tasks by title, and `paging.totalItems` counts every document in that emission’s list. The database still returns the full matching list on every change; paging only trims what is sent. Keep observed filters narrow, and use [`queryPage`](/arc/backend/typescript/mongodb/paging/) for large collections that do not need to be live. ## Limits * A full list is capped at 1,000 documents by default; `maxObservableItems` raises the cap to at most 10,000. A list that exceeds the cap, on the first read or on any later one, fails the subscription instead of sending a partial list. * A standalone MongoDB server is rejected with `MongoDB observe requires a replica set with change streams`. Replica sets and sharded clusters support change streams. * For several collections, use [joined observation](/arc/backend/typescript/mongodb/joined-observe/); each collection has its own snapshot cap. ## When the stream fails The MongoDB driver resumes the change stream after errors it knows are transient, such as a replica-set election. Your subscription does not notice. Anything else ends the subscription with a query failure: a change stream error the driver cannot resume, a read that fails, the item cap, or the change stream ending on its own (`MongoDB change stream ended`). Arc closes the cursor and reports the failure to the subscriber. It does not reopen the stream by itself. To recover, subscribe again. A new subscription opens a new change stream and reads a fresh, complete list, so the client catches up on everything that changed in between. ## Related * [Observable queries](/arc/backend/typescript/queries/observable-queries/) * [Paging](/arc/backend/typescript/mongodb/paging/) * [MongoDB](/arc/backend/typescript/mongodb/) * [Change-stream watcher](/arc/backend/typescript/mongodb/change-stream-watcher/) # MongoDB paging > Count, sort, and page a MongoDB query in the database with queryPage, and know which sort fields are accepted and what consistency to expect. Paging a large collection in memory loads every document. `MongoCollection.queryPage` pushes the count, sort, skip, and limit into MongoDB and returns an Arc page with the real total. ```typescript @query(service(tasks), queryOptions()) static async page(items: MongoCollection, options: QueryOptions) { return items.queryPage({}, options); } ``` `GET /api/.../page?page=0&pageSize=10&sortBy=title` answers with ten documents sorted by title and `paging.totalItems` counted by MongoDB. ## Rules * `queryPage(filter, options)` defaults to the first `maxPageSize` rows when paging is absent. Each read is capped at `maxPageSize`, 100 by default and at most 10,000. If the count exceeds the cap, Arc rejects the incomplete unpaged result with 400 rather than returning partial data. * Only fields declared on the model may be sorted. The Arc wire name resolves to the declared property, then to its BSON name under the selected [naming policy](/arc/backend/typescript/mongodb/naming-policies/). An unknown field, including `$where`, answers 400. * An `_id` tie-breaker makes page order stable. An application-provided default sort is used when the request asks for none. * For an intentionally unbounded list, use `items.find()`; do not use it on large collections. ## Consistency The count and the find are separate reads. A concurrent write can change the count between them; this is not a snapshot transaction. ## Related * [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/) * [Observing collections](/arc/backend/typescript/mongodb/observing-collections/) # Serializers > How MongoCollection maps decorated fields, concepts, GUIDs, dates, numbers, and derived types to BSON, what fails and why, and how to write through the driver without breaking the mapping. A task ID is a `TaskId` concept in your code, a UUID binary in MongoDB, and a string on the wire. Getting each of those conversions right by hand, in every query and every write, is how documents written by one service become unreadable to another. `MongoCollection` does it from the model’s `@field` declarations: reads return instances of your model, and the stored documents match what Arc on .NET writes. ## Storage format | Model value | Stored as | | --- | --- | | `@key()` field, or a field named `id` | `_id` | | `Guid` | Standard UUID binary (subtype 4) | | A concept | Its underlying value, stored by the same rules | | `Date` | BSON date | | `DateOnly` | BSON date at 12:00 UTC on that day | | `TimeOnly` | BSON date on 1970-01-01, UTC | | `TimeSpan` | String, such as `01:02:03.123` | | `String`, `Number`, `Boolean` | As is | | Nested decorated model | Embedded document, by the same rules | | Array with `genericArguments` | Array, each element by the same rules | | A class with Fundamentals `@derivedType('identifier')` | Its fields plus `_derivedTypeId` | Property names come from the [naming policy](/arc/backend/typescript/mongodb/naming-policies/). No global BSON conventions or class maps are installed, so other code using the same driver is unaffected. ## How reads map documents `items.find(filter)`, `items.findById(id)`, `queryPage`, and `observe` all build model instances the same way: * Only declared fields are read. A stored property with no matching `@field` is ignored. * A declared field missing from the document stays unset on the instance. * A concept field is rebuilt as an instance of the concept class. * `Number` accepts BSON doubles and integers, and `Int64` or `Decimal128` values that a JavaScript number represents exactly. A value it cannot represent exactly, such as `Decimal128('0.123456789123456789')`, fails the read with a `RangeError` rather than rounding. * For a base type with registered derivatives, `_derivedTypeId` selects the concrete class; GUID identifiers compare case-insensitively. `findById` accepts a primitive, `Guid`, concept, `ObjectId`, or UUID binary, and rejects an object such as `{ $ne: null }` so a request value cannot become a query operator. ## When mapping fails | Error | Cause | | --- | --- | | `MongoDB model requires @field metadata` | The model declares no fields | | `MongoDB model declares multiple keys` | More than one `@key()` | | `MongoDB model requires @key() or an id field` | No key | | `MongoDB model is missing _id` | A stored document has no `_id` | | `MongoDB Guid requires standard UUID binary` | A `Guid` field holds a string, or legacy UUID binary (subtype 3) | | `MongoDB number cannot be represented exactly as a JavaScript number` | A lossy `Int64` or `Decimal128` | | `MongoDB number must be finite` | A `Number` field holds something else | | `Unknown MongoDB derived type: ` | A `_derivedTypeId` with no registered class | | `Unsupported MongoDB model type: ` | A field type the codec does not map, such as a class without `@field` declarations | The first three fail when the collection is first resolved. The others fail the read, and the query answers with an error rather than a half-built model. A `Guid` written by an older .NET driver configuration as legacy UUID binary is the most common of these. Migrate such documents to standard UUID representation before reading them here. ## Write through the driver The collection has no insert or update methods of its own. Write through `items.native`, the driver collection, and encode with `items.codec` so the document matches what reads expect: ```typescript import type { Document, Filter } from 'mongodb'; await items.native.insertOne(items.codec.serialize(task)); await items.native.replaceOne({ _id: items.codec.id(task.id) } as Filter, items.codec.serialize(task), { upsert: true }); ``` `codec.id` returns `unknown`, so a filter built with it needs the `Filter` cast the package’s own code uses. `serialize` refuses an instance without a key value, so the driver never invents an `ObjectId` your model cannot read back. A plain object with the model’s fields is encoded like an instance. Reading through `native` returns raw driver documents. Pass them to `items.codec.deserialize(document)` to get a model instance. ## Existing documents in another shape `ignoreConventions: true` bypasses the codec for collections that already store driver-native documents. Reads assign the raw document’s properties to a new instance, writes store the instance’s own properties, and identities pass through unchanged. In that mode you own field names and conversion, and a model does not need a key. ## Related * [Naming policies](/arc/backend/typescript/mongodb/naming-policies/) * [Wire format reference](/arc/backend/typescript/reference/wire-format/) # MongoDB tenancy > How withMongoDB chooses a database and server for each tenant, and what a single-tenant application configures. Every Arc execution resolves a tenant, and the MongoDB collection you inject belongs to that tenant’s database. You decide the mapping; the package never falls back to a default database silently. ## Database per tenant | Configuration | Tenant `default` | Tenant `acme` | | --- | --- | --- | | `database: 'tasks'` | `tasks` | `tasks+acme` | | `databaseNameResolver: tenant => \`tasks\_${tenant}“ | `tasks_default` | `tasks_acme` | Tenant names are normalized to lowercase before a database is chosen, including names returned by `tenancy.resolve`. A missing tenant or an empty database name fails the request. ## Server per tenant `serverResolver(tenantId, context)` routes tenants to different MongoDB servers and must return a URI. It replaces `client` and `server`. ## Single-tenant applications Configure Arc tenancy with a fixed tenant of `default`: ```typescript import { ArcApplication, TenantResolverType } from '@cratis/arc.core'; const builder = ArcApplication.createBuilder({ tenancy: { resolverType: TenantResolverType.Fixed, fixedTenantId: 'default' } }); ``` With `database: 'tasks'`, every request then uses the bare `tasks` database. ## Isolation is not authorization Choosing the tenant’s database does not prove the caller belongs to that tenant. Configure a membership check or derive the tenant from the principal; see [Tenancy](/arc/backend/typescript/tenancy/). ## Related * [Get started with MongoDB](/arc/backend/typescript/mongodb/getting-started/) * [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/) # Observe Arc requests > Subscribe to Arc for TypeScript tracing, duration histograms, and command outcome counts with an application-owned OpenTelemetry SDK. A slow command in production is hard to explain from logs alone. Was the time spent in validation, in your handler, or in the HTTP layer? Arc for TypeScript emits spans for its pipeline stages, command and query duration histograms, and command outcome counts through `@opentelemetry/api`, so the tracing backend you already run can answer that. Core does not install an exporter, context manager, or SDK, so it stays usable without any tracing infrastructure. You install and start an SDK **in your application**, before you build the Arc server. Arc never sends raw exception messages to spans, including in development. ## Before you start * `@opentelemetry/api`, the required peer dependency of `@cratis/arc.core`. No exporter is required. * For the example below, a clone of this repository after `yarn install && yarn build`. The workspace provides the SDK development dependencies. * In your own application, install `@opentelemetry/sdk-node`, `@opentelemetry/sdk-trace-base`, `@opentelemetry/sdk-metrics`, and `zod` there instead of depending on the SDK from core. [Create an application](/arc/backend/typescript/getting-started/create-an-application/) shows how to install the unpublished Arc packages. ## Start a local console exporter Save the following as `observability-example.mjs` at the repository root and run `node observability-example.mjs` with Node.js 22.19 or later: ```js import { NodeSDK } from '@opentelemetry/sdk-node'; import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base'; import { ConsoleMetricExporter, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; const sdk = new NodeSDK({ traceExporter: new ConsoleSpanExporter(), metricReaders: [new PeriodicExportingMetricReader({ exporter: new ConsoleMetricExporter(), exportIntervalMillis: 1000 })], logRecordProcessors: [] }); sdk.start(); const { ArcServer, defineQuery } = await import('@cratis/arc.core'); const { z } = await import('zod'); const server = new ArcServer({ queries: [defineQuery({ name: 'Ping', schema: z.object({}), perform: () => [{ id: 1 }] })] }); try { const response = await server.handle(new Request('http://localhost/api/ping')); console.log(await response?.json()); } finally { await server.dispose(); await sdk.shutdown(); } ``` You see the query result, then spans and a `cratis.arc.query.duration` histogram from the `Cratis.Arc` tracer and meter. Both scopes carry the installed `@cratis/arc.core` package version. In a real host, replace the console exporters with exporters configured for your collector. If the SDK starts after Arc is imported, Arc uses the API’s proxy tracer. Initialize the SDK before sending requests so nothing is missed. ## Connect host HTTP spans to Arc If you need a request trace across host middleware and Arc, start your SDK **before loading** the HTTP server or adapter. The host owns W3C `traceparent` extraction and propagation; Arc emits child spans under the active context. The following Express host uses an in-memory span exporter for inspection (use your own exporter in production). It enables only HTTP and Express instrumentation, and disables metric and log exporting. Express is loaded with CommonJS `require` after SDK startup because the Express instrumentation does not hook ESM imports without an OpenTelemetry loader hook. A pure ESM host registers that hook instead (`node --import` with a module that calls `register('@opentelemetry/instrumentation/hook.mjs', import.meta.url)` before the SDK starts); that path is not covered by the check below. Install `@opentelemetry/instrumentation-http`, `@opentelemetry/instrumentation-express`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-trace-base`, `express`, and `zod` in the host application. observability-host.ts ```typescript import { NodeSDK } from '@opentelemetry/sdk-node'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; import { createRequire } from 'node:module'; import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { SpanKind } from '@opentelemetry/api'; const exporter = new InMemorySpanExporter(); const sdk = new NodeSDK({ spanProcessors: [new SimpleSpanProcessor(exporter)], metricReaders: [], logRecordProcessors: [], instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()] }); sdk.start(); const require = createRequire(import.meta.url); const { createServer } = require('node:http') as typeof import('node:http'); const express = require('express') as typeof import('express'); const { ArcServer, defineCommand } = await import('@cratis/arc.core'); const { cratisArc } = await import('@cratis/arc.express'); const { z } = await import('zod'); const arc = new ArcServer({ commands: [defineCommand({ name: 'Echo', schema: z.object({ value: z.string() }), handle: ({ value }) => value })] }); const host = express(); host.use(cratisArc(arc)); const server = createServer(host); try { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); if (!address || typeof address === 'string') throw Error('No listening port'); const response = await fetch(`http://127.0.0.1:${address.port}/api/echo`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"value":"ok"}' }); if (response.status !== 200) throw Error(`HTTP ${response.status}`); await response.text(); const spans = exporter.getFinishedSpans(); const servers = spans.filter(span => span.kind === SpanKind.SERVER && (span.attributes['http.method'] ?? span.attributes['http.request.method']) === 'POST' && (span.attributes['http.target'] ?? span.attributes['url.path']) === '/api/echo'); const arcSpans = spans.filter(span => span.name === 'cratis.arc.http.handle'); const http = servers[0]; const arcHttp = arcSpans[0]; if (servers.length !== 1 || arcSpans.length !== 1 || !http || !arcHttp || arcHttp.kind !== SpanKind.INTERNAL) { throw Error('Expected one HTTP SERVER span and one Arc INTERNAL span'); } const traceId = http.spanContext().traceId; const byId = new Map(spans.filter(span => span.spanContext().traceId === traceId) .map(span => [span.spanContext().spanId, span])); const ancestors = new Set(); let parentId = arcHttp.parentSpanContext?.spanId; while (parentId && parentId !== http.spanContext().spanId) { if (ancestors.has(parentId)) throw Error('Span ancestry has a cycle'); ancestors.add(parentId); const parent = byId.get(parentId); if (!parent) throw Error('Missing ancestor span'); parentId = parent.parentSpanContext?.spanId; } if (arcHttp.spanContext().traceId !== traceId || parentId !== http.spanContext().spanId) { throw Error('Arc span does not descend from HTTP SERVER span'); } if (![...ancestors].some(id => byId.get(id)?.instrumentationScope.name === '@opentelemetry/instrumentation-express')) { throw Error('Missing Express instrumentation span in Arc ancestry'); } console.log(http.name, '→', arcHttp.name); } finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); await arc.dispose(); await sdk.shutdown(); } ``` The host’s Node HTTP span is an ancestor of Arc’s INTERNAL `cratis.arc.http.handle` span (with Express middleware spans between them); it is not a second Arc SERVER span. `yarn check:observability-recipes` exercises **real HTTP** with Express (HTTP and Express instrumentation), Fastify (HTTP and `@opentelemetry/instrumentation-fastify`, which is deprecated in favor of the maintained `@fastify/otel`), and Hono on its Node server (HTTP instrumentation; no Hono-specific instrumentation installed). For each host it asserts exactly one HTTP SERVER span is an ancestor of the Arc span for a command; for Express and Fastify it also requires a framework instrumentation span in the ancestry. It also checks command and query duration histograms and the command outcomes counter with DELTA temporality and per-host execution counts, canonical metric attributes, the deprecated histogram emitted in parallel, tracer and meter scope versions, and the structured-logging recipe below. In-memory export proves local parenting, not remote collector delivery or trace-context propagation through a proxy. ## Log errors without exposing payloads Arc’s `logger(error, correlationId)` callback runs on the server side. Pino can serialize the error under `err` without logging a command or request body. Install `pino` in the host application; do not send this logger’s output to the client. logging.ts ```typescript import pino from 'pino'; import { ArcServer, defineCommand } from '@cratis/arc.core'; import { z } from 'zod'; const log = pino(); const arc = new ArcServer({ exposeExceptionDetails: false, commands: [defineCommand({ name: 'Fail', schema: z.object({ value: z.string() }), handle: () => { throw Error('private handler failure'); } })], logger: (error, correlationId) => log.error({ err: error, correlationId }, 'Arc request failed') }); try { const response = await arc.handle(new Request('http://localhost/api/fail', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"value":"secret"}' })); console.log(response?.status, await response?.text()); } finally { await arc.dispose(); } ``` The HTTP response is redacted outside development (explicitly set here). `yarn check:observability-recipes` asserts that a failing command logs its correlation ID and error, that the HTTP response does not reveal the error, and that the structured log does not contain the command payload. Control log access and retention according to your application’s secrets policy; the serialized `err` can contain sensitive exception details. ## Span names | Span | Boundary | Attributes | | --- | --- | --- | | `cratis.arc.command.execute` | Command execution | `cratis.arc.command.type`, `cratis.arc.command.outcome`, `command_type`, `cratis.correlation_id` | | `cratis.arc.command.validate` | Validate-only pipeline | `cratis.arc.command.type`, `cratis.arc.command.outcome`, `command_type`, `cratis.correlation_id` | | `cratis.arc.command.filter` | Command validation/filter stage | `cratis.arc.command.type`, `command_type`, `cratis.correlation_id` | | `cratis.arc.query.perform` | Snapshot query or observable source open | `cratis.arc.query.name`, `cratis.arc.query.outcome`, `cratis.arc.query.transport`, `query_name`, `cratis.correlation_id` | | `cratis.arc.query.filter` | Query validation/filter stage | `cratis.arc.query.name`, `query_name`, `cratis.correlation_id` | | `cratis.arc.http.handle` | Recognized Arc HTTP endpoint (INTERNAL) | `http.request.method`, `http.route`, `cratis.correlation_id` | | `cratis.arc.query.emission` | Observable current value or subsequent delivery | `cratis.arc.query.name`, `query_name`, `cratis.correlation_id` | | `cratis.arc.query.subscribe` | Parent scope lifetime, including observable snapshots | `cratis.arc.query.name`, `query_name`, `cratis.correlation_id` | | `cratis.arc.identity.resolve` | Identity details provider resolution | `cratis.correlation_id` | Command spans also carry `cratis.arc.command.type`; query spans, including subscriptions and emissions, also carry `cratis.arc.query.name`. These canonical attributes and the retained `command_type` and `query_name` tags contain registered qualified names, not payloads. The command execution, validation, and filter spans and the query-perform span use the .NET pipeline names. The HTTP, emission, and subscription spans are Node-specific: .NET uses ASP.NET request instrumentation and does not have a per-emission span. ## Metrics | Instrument | Unit | What it measures | | --- | --- | --- | | `cratis.arc.command.duration` | `s` | Full command execution; `cratis.arc.command.type`, `cratis.arc.command.outcome` | | `cratis.arc.command.outcomes` | `{command}` | One count per command execution; the same attributes as command duration | | `cratis.arc.query.duration` | `s` | Snapshot query execution or observable source open; `cratis.arc.query.name`, `cratis.arc.query.transport`, `cratis.arc.query.outcome` | | `cratis.arc.operation.duration` (deprecated) | `s` | Each pipeline stage and operation, tagged with `operation` and the original type, name, or route tags | | `cratis.arc.subscription.duration` | `s` | Observable subscription lifetime; `cratis.arc.query.name` and deprecated `query_name` | The four duration instruments are histograms in seconds; `cratis.arc.command.outcomes` is a monotonic counter. All have descriptions and use the `Cratis.Arc` meter. The histograms’ explicit bucket boundary advice is `[0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10]` seconds, matching Arc for .NET’s pipeline duration buckets rather than the OpenTelemetry JavaScript SDK’s millisecond-scale defaults. Your SDK’s views can override this advice; counters have no buckets. The tracer and meter scope version is the `@cratis/arc.core` package version, compiled from a generated `Version.ts` constant. `yarn set-version` updates Core’s and CodeAnalysis’s constants with the manifests; `yarn set-version --check` detects missing or stale constants. Runtime code does not import or bundle the package manifest, and builds no longer emit `dist/package.json`. The new command and query histograms measure completed executions, including failed executions. They do not count filter stages, validate-only commands, or observable emissions as additional executions. Their attributes match the canonical span keys and contain registered command types or query names and fixed outcome/transport values—never correlation IDs, tenant IDs, or payloads. Each meter retains at most 1,000 distinct command types and 1,000 query names; further names become `_other`, as in .NET. Query and subscription metrics share the query-name limit. Legacy metric names and routes are bounded too. Unknown direct command/query names are rejected before the TypeScript pipeline opens and produce no execution metric; unlike .NET, they do not produce a query metric labeled `_other`. `cratis.arc.operation.duration` is deprecated and remains emitted alongside the new instruments for one minor release. Migrate command and query latency dashboards to the new names and canonical attribute keys before it is removed; do not sum the old and new metrics, which overlap. The old instrument retains its original measurements and attribute keys during this transition, with name overflow folded into `_other`. Subscription duration now records `cratis.arc.query.name` alongside `query_name`; the old key remains for one minor release. ### Outcomes and transports Outcome classification follows Arc for .NET’s result classifier in this order: 1. Unauthorized results use `authorization`, even if they also contain exceptions or validation failures. 2. Exception results (or thrown failures without a result) use `cancelled` if the execution’s `AbortSignal` is aborted, otherwise `error`. An `AbortError` alone does not imply cancellation. 3. Results without validation issues use `success`, even if the signal was subsequently aborted. 4. Validation with `constraintViolation` or `concurrencyViolation` reasons uses `append_rejected`; other validation uses `validation`. Chronicle append rejections already carry these reasons. Messages, members, and arbitrary reason values never become metric labels. Commands use all six values. Queries normally use `success`, `validation`, `authorization`, `cancelled`, and `error`; as with .NET’s shared classifier, a custom query filter returning a constraint/concurrency validation reason is classified as `append_rejected`. The outcome is also recorded on execute/validate/perform spans. Existing TypeScript span error-status behavior is unchanged: exception results still mark spans as errors, including cancellation; .NET marks only the `error` outcome as a failed span. `cratis.arc.query.transport` uses exactly the .NET values: * `snapshot`: an ordinary query result, including successful null/undefined data. * `observable`: an observable source returned by the query pipeline. This includes current-value snapshots, SSE, WebSocket, and multiplexed-hub subscriptions; those delivery protocols do not get separate metric values. * `unknown`: a failed result without data, or an exception without a result, including authorization or validation rejection before a source opens. Transport describes the returned result, not the requested protocol. Both TypeScript observable sources and .NET subjects/async enumerables map to `observable`. Emissions and subscription closure do not add query-duration measurements or change the source-open outcome. Validate-only commands do not increment the command counter. ### Public constants Import `WellKnownTelemetryNames`, `OperationOutcome`, and `QueryTransport` from `@cratis/arc.core`. The enums expose the values above. `WellKnownTelemetryNames` exposes: * Scope and instruments: `scope`, `commandDuration`, `commandOutcomes`, `queryDuration`, `operationDuration` (deprecated), `subscriptionDuration`. * Attributes: `commandType`, `commandOutcome`, `queryName`, `queryOutcome`, `queryTransport`. * Spans: `commandExecuteSpan`, `commandValidateSpan`, `commandFilterSpan`, `queryPerformSpan`, `queryFilterSpan`, `queryEmissionSpan`, `querySubscribeSpan`, `httpHandleSpan`, `identityResolveSpan`. ## What Arc does not instrument * **Foreign routes.** Routes your host serves outside Arc get no Arc spans. Use your host’s HTTP instrumentation for those. * **W3C trace context.** A valid configured [correlation header](/arc/backend/typescript/reference/capabilities/#security-identity-tenancy-and-correlation) is reflected in `cratis.correlation_id` and in the response. It is not a W3C trace-context propagator; configure your host instrumentation for `traceparent`. The Kotlin/JVM adapter uses Micrometer observations; it is not an OpenTelemetry SDK dependency of the TypeScript core. ## Next steps * [Configuration](/arc/backend/typescript/configuration/) sets the correlation header name with `correlationId.httpHeader`. * [Diagnostics](/arc/backend/typescript/reference/diagnostics/) lists the other signals Arc produces when something goes wrong. # OpenAPI > Hand API consumers and tools an OpenAPI 3.1 description of every command and query at /openapi.json, with request schemas, result envelopes, and bearer security, without writing it by hand. A partner team wants to call your task API from Python. A QA engineer wants the endpoints in their API client. Your gateway wants a contract to validate against. Writing that description by hand means it is wrong the week after someone adds a field. Arc writes it for you. Arc can serve an OpenAPI 3.1 document at `GET /openapi.json`, built from the same `@field` declarations and Zod schemas that bind requests. When the code changes, the document changes with it. ## Fetch the document In Development, no credentials are required by default. Elsewhere `/openapi.json` follows the shared [discovery access policy](/arc/backend/typescript/introspection/#production-access): authenticated callers only, or unmapped when no authentication is configured. The Tasks sample explicitly selects Development. ```bash curl http://127.0.0.1:3000/openapi.json ``` For the Tasks sample, with its generated metadata registered, the `RegisterTask` command appears under `paths` like this (excerpt, `responses` left out): ```json "/api/tasks/registration/register-task": { "post": { "operationId": "Tasks.Registration.RegisterTask", "tags": ["Tasks.Registration"], "summary": "Register a task.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "id": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "format": "uuid" }, "title": { "type": "string" } }, "required": ["id", "title"] } } } } } } ``` The summary is the JSDoc comment on the `RegisterTask` class. `TaskId` and `TaskTitle` are concepts, so they appear as the UUID string and the string they wrap. Fetch the document from your running server to see the responses in full. Arc generates the document without extra registration, but HTTP access follows the [discovery access policy](/arc/backend/typescript/introspection/#production-access): anonymous only in Development by default, authenticated elsewhere, and unmapped outside Development without authentication configured. Arc does not bundle a Swagger or Scalar UI; point one you host at `/openapi.json` and configure its credentials if you want a browsable page. ## What each operation contains * **Identity.** A namespace-qualified `operationId`, such as `Tasks.Registration.RegisterTask`, a tag for the namespace, and a summary. Route paths follow [endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/). Every command also has a `POST /validate` operation, with `:validate` appended to its execute operationId (for example, `Tasks.Registration.RegisterTask:validate`), even if the command itself is named `Validate`. * **Input.** Execute and validate share the same required JSON request body and security requirements. Queries expose their arguments as GET parameters, with `required` taken from the input schema. Queries with declared array or `queryPage` results also advertise `page`, `pageSize`, `sortBy`, and `sortDirection`. Observable queries add `waitForFirstResult` and `waitForFirstResultTimeout`. * **Responses.** The Arc `CommandResult` or `QueryResult` envelope for 200, 400, 403, and 500. When default authentication handlers run (even on anonymous routes), a named scheme is selected, or a native principal is required, command execution, validation, and query GET also describe 401 with an untyped result envelope. The validation-only operation describes an **untyped** command result; it does not execute the handler or return a typed `response`. Observable queries also describe 202, 408, and 503, and a `text/event-stream` response. A paged result carries `paging` with `page`, `size`, `totalItems`, and `totalPages`. * **Security.** HTTP bearer security, when the operation authenticates with a `jwtBearer()` handler. ## Topics | Page | What it covers | | --- | --- | | [Concepts](/arc/backend/typescript/open-api/concepts/) | Concepts, GUIDs, dates, and models described as the JSON value they carry | | [Commands](/arc/backend/typescript/open-api/commands/) | The POST operation, request body, `CommandResult` envelope, and typed `response` | | [Queries](/arc/backend/typescript/open-api/queries/) | GET parameters, paging and sorting, observable options, and the `QueryResult` envelope | | [Enums](/arc/backend/typescript/open-api/enums/) | `@enumeration` values for numeric and string enums | | [Model-bound and low-level operations](/arc/backend/typescript/open-api/model-bound/) | Where each part of an operation comes from, for decorators and `define*` definitions | | [How types appear in the document](/arc/backend/typescript/open-api/schemas/) | One worked example, plus optional, nullable, defaulted, and derived fields | Arc on .NET also documents its C#-only `[FromRequest]` binding; Arc for TypeScript binds a command from the JSON body and a query from its arguments, so it has no counterpart. ## Summaries and result types need generated metadata Arc reads your source only through the metadata it has at runtime. Two parts of the document depend on [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) registered with `useGeneratedMetadata()`: * **Summaries.** JSDoc on a command class or query method becomes the operation summary. Without metadata the summary is empty. A low-level definition sets `summary` directly. * **Result types.** The 200 envelope includes a typed `response` or `data` only when the metadata declares the return type. Without it, the document **omits** `response` or `data` rather than guess from the input schema or run the handler. The runtime result is the same; only its description is missing. The Tasks sample registers its metadata, so its `registerTask` response is described as a UUID string and `allTasks` as an array of `TaskItem`. Without declared return cardinality, Arc cannot determine before execution whether a query will return an array, `queryPage` result, or scalar. The runtime still accepts paging and sorting on array or `queryPage` results, but the document conservatively omits those parameters for unknown results. Renderer-backed (`QueryRenderer`) queries aren’t automatically advertised as pageable: the renderer’s output is not known before execution. The four standard parameters use the same names and descriptions as the .NET OpenAPI integration. TypeScript additionally documents nonnegative `page`, positive `pageSize`, and the `ascending`/`descending` aliases accepted by its HTTP binder; both use `int32` for the page parameters, while .NET lists only `asc`/`desc` and does not document these bounds. For a low-level array query declared with `defineQuery`, put `generatedReturn` on the descriptor to advertise paging without generated artifact metadata: allTasks.ts ```typescript import { defineQuery } from '@cratis/arc.core'; import { z } from 'zod'; export const allTasks = defineQuery({ name: 'AllTasks', schema: z.object({}), generatedReturn: { cardinality: 'many', nullable: false }, perform: () => [] as string[] }); ``` The same field works on `defineObservableQuery` descriptors; use `cardinality: 'paged'` for a declared `queryPage` result instead of `'many'`. `GeneratedReturn` is exported as a type from `@cratis/arc.core` if you need to declare the shape separately. Declaring cardinality is a contract for the result, not a way to turn an arbitrary scalar into a pageable query. ## Set the advertised version `info.version` defaults to `0.1.0`. Set your application’s version with `generatedApis.openApiVersion`, in code or as `Cratis:Arc:GeneratedApis:OpenApiVersion` in configuration: ```typescript const server = new ArcServer({ generatedApis: { openApiVersion: '2.3.0' } }); ``` ## Bearer security An operation that requires authentication advertises HTTP bearer security when it authenticates with a default `jwtBearer()` handler, or explicitly selects a named JWT scheme. Anonymous operations advertise none. A named-only handler does not authenticate operations that use the default handlers. If a named scheme is called `bearer`, the default bearer component is called `arcBearer`. Arc cannot infer the protocol of a custom handler, so it never advertises one as bearer. A [native principal](/arc/backend/typescript/hosts/native-principal/) is authenticated by the host and has no security scheme in this document. ## What the document leaves out * **HTTP `QUERY`.** OpenAPI path items cannot represent it, so queries appear as GET only. * **The `/.cratis` endpoints.** [Introspection](/arc/backend/typescript/introspection/) describes those. * **`pathBase`.** Paths are not rewritten for a standalone host `pathBase`. * **Every status the runtime can return.** The document describes the common statuses, but does not list every possible HTTP response. ## Recap * `GET /openapi.json` is generated from the same schemas that bind requests and follows the shared [discovery access policy](/arc/backend/typescript/introspection/#production-access). * Register generated metadata to get summaries and typed results. * Set `generatedApis.openApiVersion` to advertise your version. Next, see [how concepts appear in the document](/arc/backend/typescript/open-api/concepts/). # Commands in the document > See how each Arc command appears in /openapi.json as a POST operation with a JSON request body, a CommandResult envelope for 200, 400, 403, and 500, and a typed response when generated metadata declares one. A client that calls your command needs to know three things: what to send, what comes back when it works, and what comes back when it does not. Arc answers all three from the command itself. Every command is described the same way, so a client handles one envelope shape for success and failure. ## One POST operation per command The Tasks sample’s `RegisterTask` command appears as: | Part | Value | | --- | --- | | Method and path | `POST /api/tasks/registration/register-task`, from [endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) | | `operationId` | `Tasks.Registration.RegisterTask`, the namespace-qualified command name | | `tags` | `["Tasks.Registration"]`, the namespace | | `summary` | `Register a task.`, the JSDoc on the class, when [generated metadata](/arc/backend/typescript/open-api/model-bound/#summaries-and-result-types) is registered | | `requestBody` | Required, `application/json`, the command’s input schema | The request body schema is built from the command’s `@field` declarations, with the rules on [Concepts in the document](/arc/backend/typescript/open-api/concepts/) and [How types appear in the document](/arc/backend/typescript/open-api/schemas/). A [low-level definition](/arc/backend/typescript/open-api/model-bound/) uses its Zod schema instead. ## Responses Every command documents four responses, all with the same `CommandResult` envelope, plus 401 when authentication can reject the request (see [401 responses](#401-responses)): | Status | Meaning | | --- | --- | | 200 | The command ran | | 400 | Validation failed, or the request body was malformed | | 403 | The caller is not authorized | | 500 | An unexpected server error | The envelope has these properties, all required: | Property | Type | | --- | --- | | `correlationId` | string | | `isSuccess`, `isAuthorized`, `isValid`, `hasExceptions` | boolean | | `validationResults` | array of `{ severity, message, members, reason, reasonDetail, state }` | | `exceptionMessages` | array of strings | | `exceptionStackTrace` | string | | `authorizationFailureReason` | string | ## The typed response When generated metadata declares what `handle()` returns, the 200 envelope adds an optional `response` property with that type. `RegisterTask.handle()` returns a `TaskId`, so its `response` is described as a UUID string, not as a `TaskId` object. The 400, 403, and 500 envelopes never include `response`, because a failed command returns none. A command that returns nothing has no `response` property either. Without generated metadata, the document leaves `response` out rather than guess it from the input or run the handler. The runtime response does not change; only its description is missing. ## The validation-only operation Each command also has a `POST /validate` operation, which runs authorization, filters, and validation without the handler. It shares the execute operation’s request body, tag, and security requirements; its operationId is the execute operationId with `:validate` appended, such as `Tasks.Registration.RegisterTask:validate`. Its responses use the untyped command result envelope, because validation never returns a `response`. ## 401 responses When authentication can reject a request, both the execute and the validation-only operation also list 401 with the untyped envelope: when default authentication handlers are configured (they run even on anonymous routes), when the command selects a named scheme, or when a host-supplied native principal is required on a protected route. Without any of these, Arc never answers 401 and the operations don’t list it. ## What is not described * **Routes outside Arc.** Arc for TypeScript has no controllers. Routes you add to Express, Fastify, or Hono yourself are not Arc commands and do not appear. There is no opt-out attribute, because every Arc command uses the envelope. ## Related * [Queries in the document](/arc/backend/typescript/open-api/queries/) * [Command outcomes](/arc/backend/typescript/commands/command-outcomes/) for how the envelope is filled at runtime * [OpenAPI](/arc/backend/typescript/open-api/#bearer-security) for bearer security on protected commands # Concepts in the document > See how ConceptAs values, GUIDs, dates, and field models are described in /openapi.json as the JSON value they carry on the wire, and why concept validators do not become schema constraints. Your command declares `@field(TaskId) id`. A client generator in Go or Python must not see a `TaskId` object with a `value` inside it, because the JSON on the wire carries a plain UUID string. Arc describes every concept as the value it wraps, so the document matches what the client actually sends and receives. ## A concept becomes the value it wraps The Tasks sample declares `TaskId` as a `ConceptAs` and `TaskTitle` as a `ConceptAs`. The `taskById` query takes a `TaskId` argument, and `/openapi.json` describes it as a UUID string: ```json { "name": "id", "in": "query", "required": true, "schema": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "format": "uuid" } } ``` The same rule applies wherever a concept appears: a command’s request body, a query argument, a nested model field, a command `response`, or query `data`. The `registerTask` command returns a `TaskId`, so its 200 envelope describes `response` as the same UUID string. The concept’s name never appears in the document, because nothing on the wire carries it. A concept class needs `static readonly valueType`, because TypeScript erases the generic argument of `ConceptAs`. Arc reads that marker to pick the schema, and rejects a concept without it; see [Concepts](/arc/backend/typescript/concepts/). ## Declared types and their schemas | Declared type | Schema | | --- | --- | | `String`, or a concept over it | `{ "type": "string" }` | | `Number`, or a concept over it | `{ "type": "number" }` | | `Boolean`, or a concept over it | `{ "type": "boolean" }` | | `Guid`, or a concept over it | A string with `format: "uuid"` and a UUID pattern | | `Date` | A string with `format: "date-time"` | | `DateOnly` / `TimeOnly` | A string with `format: "date"` / `format: "time"` and a pattern | | `TimeSpan` | A string with a .NET-style `[-][d.]hh:mm:ss[.fffffff]` pattern | | `@field(Array, { genericArguments: [Element] })` | An array of the element’s schema | | A `@field` model class | An inline object schema of its fields | These rules come from the wire schema in `Source/Core/reflection/wireSchema.ts`, the same code that validates incoming requests and serializes results. Arc on .NET maps `int` and `long` concepts to `integer`. A JavaScript `number` has no integer type, so a numeric concept here is `number`. Nonfinite values travel as the strings `"NaN"`, `"Infinity"`, and `"-Infinity"`; see [Wire format](/arc/backend/typescript/reference/wire-format/#enums-and-numbers). ## Validators are not schema constraints A concept validator, such as “at most 100 characters”, does not become `maxLength` in the schema. Arc runs the validator when the request arrives and returns its failure as a validation result in the 400 envelope. The schema only says `string`. Tell API consumers about those rules in your own documentation, or let them discover the rule from the validation result. ## Related * [Concepts](/arc/backend/typescript/concepts/) for declaring concepts and their validators * [Enums](/arc/backend/typescript/open-api/enums/) for restricting a scalar to a set of values * [How types appear in the document](/arc/backend/typescript/open-api/schemas/) for optional, nullable, defaulted, and derived fields # Enums in the document > Restrict a number or string field to an enum's values with @enumeration, and see how numeric and string enums are described in /openapi.json exactly as they travel on the wire. A client generator needs to know that `priority` accepts only a few values, and which ones. Arc lists the enum’s values in the schema, the same values the wire carries, so a client that follows the document sends valid input. ## Declare the field A TypeScript enum is not a runtime type that `@field` accepts. Declare the scalar with `@field(Number)` or `@field(String)`, and restrict it with `@enumeration(Enum)`: Features/Tasks/Prioritizing.ts ```typescript import { field, ConceptAs, Guid } from '@cratis/fundamentals'; import { command, enumeration } from '@cratis/arc.core'; export class TaskId extends ConceptAs { static readonly valueType = Guid; } export enum Priority { Low, Normal, High } export enum Status { Open = 'open', Done = 'done' } @command({ namespace: 'Tasks' }) export class PrioritizeTask { @field(TaskId) id!: TaskId; @field(Number) @enumeration(Priority) priority!: Priority; @field(String) @enumeration(Status) status!: Status; handle(): void {} } ``` The request body describes both fields as one `const` per member value: ```json "priority": { "anyOf": [{ "type": "number", "const": 0 }, { "type": "number", "const": 1 }, { "type": "number", "const": 2 }] }, "status": { "anyOf": [{ "type": "string", "const": "open" }, { "type": "string", "const": "done" }] } ``` Without `@enumeration`, `priority` is described as any number and Arc accepts any number at runtime. ## Numeric and string enums * A **numeric enum** such as `Priority` is described as the numbers `0`, `1`, and `2`. Arc ignores the reverse mappings TypeScript adds to numeric enums, so the names `Low`, `Normal`, and `High` do not appear. * A **string enum** such as `Status` is described as its string values, `open` and `done`. If your consumers need readable names, use a string enum: its values are what they see in the document and on the wire. [Wire format](/arc/backend/typescript/reference/wire-format/) explains how numeric enums compare with .NET. The values must match the declared scalar. `@field(String) @enumeration(Priority)` fails when Arc builds the schema, because `Priority` has number values. ## Difference from Arc on .NET The enum transformer in Arc’s ASP.NET Core OpenAPI integration lists member names under an `integer` type, while the .NET wire carries numbers. Arc for TypeScript describes the values it actually sends. ## Related * [Concepts in the document](/arc/backend/typescript/open-api/concepts/) * [How types appear in the document](/arc/backend/typescript/open-api/schemas/) # Model-bound and low-level operations > See where each part of an operation in /openapi.json comes from for model-bound commands and queries and for defineCommand and defineQuery definitions, and why summaries and result types need generated metadata. Arc has two ways to declare an operation: model-bound classes with decorators, and low-level `defineCommand`, `defineQuery`, and `defineObservableQuery` definitions. Both appear in the same document through the same code, so there is no separate registration and no operation that falls through the cracks. What differs is where Arc gets each piece of information. ## Where each part comes from | Part of the operation | Model-bound command or query | Low-level definition | | --- | --- | --- | | Route | [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/) from namespace and name | The same, or the definition’s `path` | | `operationId` | `Namespace.Command`, or `Namespace.ReadModel.method` for a query | `Namespace.Name` | | `summary` | JSDoc on the command class or query method, through generated metadata | The definition’s `summary` field | | Input schema | `@field` declarations and query arguments, through the [wire rules](/arc/backend/typescript/open-api/concepts/) | `z.toJSONSchema` of the definition’s Zod `schema` | | `response` or `data` | The declared return type, through generated metadata | Not described | | Security | Authorization decorators and registered bearer handlers | The `authorization` property and registered bearer handlers | In the Tasks sample, the `RegisterTask` command is `Tasks.Registration.RegisterTask`, and the `allTasks` query on the `TaskItem` read model is `Tasks.Listing.TaskItem.allTasks`. The low-level `create` command in [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/) is `Tasks.Create`. ## Summaries and result types Arc reads your source only through the metadata it has at runtime. JSDoc comments and TypeScript return types are gone after compilation, so two parts of a model-bound operation need [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) registered with `builder.useGeneratedMetadata(metadata)`: * **Summaries.** Without metadata, `summary` is an empty string. * **Result types.** Without metadata, the 200 envelope has no `response` or `data` property, and every query gets the paging parameters, because Arc cannot tell a single result from a list. A low-level definition sets `summary` itself. Arc does not infer its result type from the handler, so its 200 envelope has no `response` or `data`, and a low-level query always lists the paging parameters. ## Input schemas from Zod A low-level definition’s schema is converted with Zod’s own JSON Schema conversion, so it follows Zod’s rules rather than Arc’s wire rules: * An object schema sets `additionalProperties: false`. * In a command’s request body, a `.optional()` field is not required, but a `.default(...)` field is listed as required and carries its `default`. * For a query’s parameters, Arc marks an argument required only when it rejects `undefined`, so `.optional()` and `.default(...)` arguments are both optional. Keep low-level schemas convertible to JSON Schema; see [Command filters](/arc/backend/typescript/commands/command-filters/#keep-the-schema-for-shape). ## Related * [Commands in the document](/arc/backend/typescript/open-api/commands/) * [Queries in the document](/arc/backend/typescript/open-api/queries/) * [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/) # Queries in the document > See how each Arc query appears in /openapi.json as a GET operation with query-string arguments, paging and sorting parameters, observable-query options, and a QueryResult envelope with typed data. A client calling `allTasks` wants to know which arguments it may pass, whether it can ask for page two sorted by title, and what shape the rows have. Arc describes each query as a GET operation with its arguments, the paging and sorting parameters the runtime honors, and the `QueryResult` envelope around the data. ## One GET operation per query The Tasks sample’s `TaskItem.taskById` query appears as `GET /api/tasks/listing/task-by-id` with `operationId` `Tasks.Listing.TaskItem.taskById` and tag `Tasks.Listing`. Queries accept HTTP `QUERY` too, but OpenAPI has no path item for that method, so the document lists GET only. Each query argument becomes a `query` parameter. `required` comes from the input schema: an argument declared with `argument(name, Type, { optional: true })`, or a Zod field that accepts `undefined`, such as one with `.optional()` or `.default(...)`, is not required. `taskById(id: TaskId)` has one required parameter, `id`, described as a UUID string. ## Paging and sorting parameters Arc adds four parameters to a query whose result pages: | Parameter | Schema | | --- | --- | | `page` | integer (`int32`), minimum 0 | | `pageSize` | integer (`int32`), minimum 1 | | `sortBy` | string | | `sortDirection` | `asc`, `ascending`, `desc`, or `descending` | A query’s result pages when its return is declared as an array or a `queryPage` result, through generated metadata or `generatedReturn` on a low-level definition. A query declared to return one item, such as `taskById`, or nothing, gets no paging parameters, and neither does a query whose return Arc doesn’t know or a renderer-backed query: the runtime still pages those, but the document only advertises what it can prove. `allTasks` returns `TaskItem[]`, so it lists all four. See [Summaries and result types need generated metadata](/arc/backend/typescript/open-api/#summaries-and-result-types-need-generated-metadata) for declaring `generatedReturn`. `page`, `pageSize`, `sortBy`, and `sortDirection` are reserved query-string names, compared case-insensitively: Arc reads them as paging and sorting and removes them before binding the query’s arguments, so a query argument with one of these names never receives the value. Name your own arguments differently. These parameters describe what a client may send. How a query honors them depends on its result; see [Paging](/arc/backend/typescript/queries/model-bound/paging/). ## Observable queries An [observable query](/arc/backend/typescript/queries/observable-queries/), such as `observeAllTasks`, adds two parameters and three responses: | Addition | Meaning | | --- | --- | | `waitForFirstResult` | boolean; wait for the first value instead of answering 202 | | `waitForFirstResultTimeout` | number of seconds, greater than 0 and at most 120 | | 200 `text/event-stream` | The Server-Sent Events stream, next to the JSON snapshot | | 202 | No current value yet | | 408 | The first-result wait timed out | | 503 | The subscription limit was reached | WebSocket and multiplexed hub transports are not described. [Using observable queries with curl](/arc/backend/typescript/queries/using-observable-queries-with-curl/) shows each HTTP answer. ## Responses Every query documents 200, 400, 403, and 500, all with the `QueryResult` envelope, and 401 when authentication can reject the request (see [401 responses](/arc/backend/typescript/open-api/commands/#401-responses)). It carries the same `correlationId`, `isSuccess`, `isAuthorized`, `isValid`, `hasExceptions`, `validationResults`, `exceptionMessages`, and `exceptionStackTrace` properties as a [command’s envelope](/arc/backend/typescript/open-api/commands/#responses), plus: | Property | Type | | --- | --- | | `isReady` | boolean | | `paging` | `{ page, size, totalItems, totalPages }`, all integers | | `data` | The result type; 200 only, and only when generated metadata declares it | `data` follows the result’s cardinality. `allTasks` has an array of `TaskItem` objects. `taskById` returns `TaskItem | undefined`, so its `data` is `anyOf` the `TaskItem` object and `null`. Result object schemas set `additionalProperties: false`. Error envelopes (400, 403, 500, and the observable 202, 408, and 503) never include `data`. ## Differences from Arc on .NET * The sort parameter is `sortBy`. The .NET transformer names it `sortby`. * Required arguments are marked `required: true`. The .NET model-bound transformer marks every argument optional. * Paging parameters follow the declared result, not a runtime enumerable check. ## Related * [Commands in the document](/arc/backend/typescript/open-api/commands/) * [Model-bound and low-level operations](/arc/backend/typescript/open-api/model-bound/) * [Query arguments](/arc/backend/typescript/queries/model-bound/query-arguments/) # How types appear in the document > See how concepts, dates, enums, optional, nullable, and defaulted fields, and result types are described in /openapi.json and the introspection schemas, and why they match the wire format. Your command declares `@field(TaskId) id`. A client generator in Go or Python must not see a `TaskId` object with a `value` inside it, because the JSON on the wire carries a plain UUID string. Arc builds every schema in the document from the same wire rules that bind and serialize requests, so what the document says is what the wire carries. The same schemas appear in the [introspection](/arc/backend/typescript/introspection/) endpoints: a command’s `payloadSchema` in `/.cratis/commands` is identical to its OpenAPI request body schema. ## An example This command uses concepts, enums, and each field modifier: Features/Tasks/Planning.ts ```typescript import { field, ConceptAs, Guid } from '@cratis/fundamentals'; import { command, defaultValue, enumeration, nullable, optional } from '@cratis/arc.core'; export class TaskId extends ConceptAs { static readonly valueType = Guid; } export class Estimate extends ConceptAs { static readonly valueType = Number; } export enum Priority { Low, Normal, High } export enum Status { Open = 'open', Done = 'done' } @command({ namespace: 'Tasks' }) export class PlanTask { @field(TaskId) id!: TaskId; @field(Estimate) estimate!: Estimate; @field(Number) @enumeration(Priority) priority!: Priority; @field(String) @enumeration(Status) status!: Status; @field(String) @optional() note?: string; @field(Date) @nullable() due!: Date | null; @field(Boolean) @defaultValue(false) urgent!: boolean; handle(): void {} } ``` Its request body schema in `/openapi.json`: ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "id": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "format": "uuid" }, "estimate": { "type": "number" }, "priority": { "anyOf": [{ "type": "number", "const": 0 }, { "type": "number", "const": 1 }, { "type": "number", "const": 2 }] }, "status": { "anyOf": [{ "type": "string", "const": "open" }, { "type": "string", "const": "done" }] }, "note": { "type": "string" }, "due": { "anyOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, "urgent": { "default": false, "type": "boolean" } }, "required": ["id", "estimate", "priority", "status", "due"] } ``` The sections below explain each property. Concepts, enums, and result types have their own pages; this page covers the field modifiers and derived types that apply to all of them. ## Concepts A concept is described as the value it wraps: `id` is a UUID string, and `estimate` is a number. The name `TaskId` appears nowhere, because nothing on the wire carries it. [Concepts in the document](/arc/backend/typescript/open-api/concepts/) has the table for every declared type, and explains why concept validators do not become schema constraints. ## Enums `@enumeration(Enum)` lists the enum’s values, one `const` per member: `priority` is `0`, `1`, or `2`, and `status` is `open` or `done`. [Enums in the document](/arc/backend/typescript/open-api/enums/) covers numeric and string enums and the difference from Arc on .NET. ## Optional, nullable, and default values | Field | Schema | Required? | | --- | --- | --- | | `@field(String) note` | `string` | Yes | | `@field(String) @optional() note` | `string` | No; the property may be left out | | `@field(Date) @nullable() due` | `anyOf` the type and `null` | Yes; the property must be present, and may be `null` | | `@field(Boolean) @defaultValue(false) urgent` | `boolean` with `default: false` | No; Arc fills in the default | ## Derived types In input schemas, a field whose type has registered `@derivedType('id')` subclasses is described with `oneOf`, one variant per subclass, each carrying its `_derivedTypeId`. See [Wire format](/arc/backend/typescript/reference/wire-format/) for the rules. ## Result types Command responses and query data use the same rules, with two differences: they appear only when [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) declares the return type, and output object schemas set `additionalProperties: false`. See [Commands in the document](/arc/backend/typescript/open-api/commands/#the-typed-response) and [Queries in the document](/arc/backend/typescript/open-api/queries/#responses). ## Related * [OpenAPI](/arc/backend/typescript/open-api/) * [Model-bound and low-level operations](/arc/backend/typescript/open-api/model-bound/), for where each part of an operation comes from * [Concepts](/arc/backend/typescript/concepts/) * [Command introspection](/arc/backend/typescript/introspection/commands/) # Hosting overview > Choose between the standalone Node host and the Express, Fastify, or Hono adapters, and see how the application model stays the same in every host. You have written a command and a read model, and now something has to listen on a port. Maybe your team already runs Express, or you want nothing between Arc and Node’s own HTTP server. Arc for TypeScript keeps those decisions apart: **the core owns the application model**, and **the host only delivers requests to it**. Your commands, queries, validators, and services do not change when you change host. ## Choose the host | Host | Package and entry point | Use it when | | --- | --- | --- | | Standalone Node host | `@cratis/arc.core`: `app.run()` or `app.start()` | You want Arc routes, and optionally a built frontend, on one port with no web framework | | Your own Node server | `@cratis/arc.core`: `createArcNodeHandler(server)` | You already own a `node:http` or `node:https` server and want Arc as its request handler | | Express 5 | `@cratis/arc.express`: `app.use(cratisArc(arcApp))` | Your application already uses Express middleware and routes | | Fastify 5 | `@cratis/arc.fastify`: `app.register(cratisArc, { arc: arcApp })` | Your application already uses Fastify plugins and hooks | | Hono 4 | `@cratis/arc.hono`: `app.use(cratisArc(arcApp))` | Your application already uses Hono, on Node through `@hono/node-server` | | Any Fetch API host or none | `@cratis/arc.core`: `server.handle(request)` | You call Arc from a spec, a job, or a runtime that speaks `Request` and `Response` | ``` app.run / app.start cratisArc adapters app.server.handle ArcApplication.createBuilder() add, discover, services ArcApplication app.server: ArcServer Standalone Node host Your web framework Fetch API Request ``` Every host runs the same pipeline: authentication, tenant resolution, authorization, validation, then your code. A difference between hosts is either a limit of the host framework, documented on its page, or a bug. ## What the host owns and what it does not The host owns the listener, TLS, and shutdown order. Arc owns routes, body parsing, the result envelope, status codes, correlation, and cancellation of `context.signal` when a client disconnects. Adapters hand Arc the raw body, so an adapter must be mounted before any body parser that would consume it. [Host adapters](/arc/backend/typescript/hosts/) lists what each adapter does with paths and bodies. Observable queries add one more concern: WebSocket upgrades. The standalone host handles them itself; each framework adapter has a separate mount call because Express, Fastify, and Hono accept upgrades differently. See [WebSockets](/arc/backend/typescript/hosts/websockets/). ## Then choose storage and client output Arc does not require a database or an event store. A command can call your own service, write to storage through an optional integration, or return a response. Add what the application needs: * [MongoDB](/arc/backend/typescript/mongodb/) for tenant-scoped collections with change-stream observation. * [SQL with Drizzle](/arc/backend/typescript/sql/) for tenant-scoped SQLite and PostgreSQL reads. * [Chronicle](/arc/backend/typescript/chronicle/), experimental, when commands should append events. [Proxy generation](/arc/backend/typescript/proxy-generation/) is a separate build step. It reads your TypeScript source and writes typed clients for the published `@cratis/arc` frontend runtime; it never queries a running server. ## Continue * [Arc.Core](/arc/backend/typescript/core/): the standalone Node host, its listener options, and shutdown. * [Build an application](/arc/backend/typescript/core/getting-started/): the builder, discovery, and explicit catalogs. * [Host adapters](/arc/backend/typescript/hosts/): Express, Fastify, and Hono. * [Configuration](/arc/backend/typescript/configuration/): every option the builder and server accept. # Proxy generation > Generate typed @cratis/arc frontend proxies, React hooks, and client validation from your decorated TypeScript commands and read models. A frontend that calls your commands and queries through hand-written `fetch` calls drifts from the server the first time someone renames a field. The route changes, a required property becomes optional, and nothing fails until a user clicks the button. `arc-proxygenerator` removes that drift. It reads your TypeScript project and writes typed proxies for the published `@cratis/arc` client: command and query classes, React hooks, models, and the client-safe part of your validators, all with the routes the server serves. Rename a field on the server, regenerate, and the frontend compiler shows you every place that needs to change. Source preview `@cratis/arc.proxygenerator` is not published to npm. Run its CLI from a clone of this repository after `yarn build`, or install a packed tarball in your own project, as [Create an application](/arc/backend/typescript/getting-started/create-an-application/) shows. ## How it works ``` optional Decorated TypeScript @command, @readModel, validators arc-proxygenerator TypeScript compiler API Proxies, models, hooks, client validators Frontend build @cratis/arc, @cratis/arc.react Generated artifact metadata for the server ``` The generator reads source through the TypeScript compiler API. It never imports or runs your application, and it never talks to a running server. That makes it safe to run in CI and in watch mode, and it means everything it knows comes from your declarations: decorators, `@field` types, return types, and validator rules. It walks the artifacts folder with the same rules as `builder.discover()`, so `for_*` and `given` folders, `index.ts`, and `*.proxy.ts` files are skipped there too. Route options such as `--api-prefix` and `--segments-to-skip` must match the server’s [endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/), because the generator cannot ask the server which routes it chose. ## What you receive * **Commands**: a class per command with typed properties, `execute()`, the route, client-side validation, and a React `use()` hook. * **Queries**: a class per query method, snapshot or observable, with a parameters interface when the query takes arguments, sort helpers, and React hooks including paging. * **Models**: classes with `@field` metadata, so the client can turn JSON into `Guid` values, dates, and nested models. A concept arrives as its underlying type. * **Identity details**: the `detailsType` of an [identity details provider](/arc/backend/typescript/identity/provider-flow/), ready for `useIdentity`. * **Barrels for separate output**: an `index.ts` per folder by default when proxies go to a dedicated output folder. Co-located output skips generated barrels. * **Server metadata**, optionally: with `--metadata`, a module the server registers with `useGeneratedMetadata` to infer service and argument bindings. See [Generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/). [What the generator writes](/arc/backend/typescript/proxy-generation/generated-code/) shows each of these for the Library sample. ## Compatibility The generated proxies target `@cratis/arc` and `@cratis/arc.react` 22.45.0 with `@cratis/fundamentals`, compiled in strict `Bundler` mode with `skipLibCheck: false`. Imports between generated files are extensionless by default, which suits Vite and other bundlers. Use `--js-import-specifiers` for native Node ESM after compilation. `NodeNext` consumer compilation is not supported with the published client declarations. ### Comparison with .NET 22.45.0 The repository’s [paired-generator comparison](https://github.com/Cratis/Arc.TypeScript/tree/main/ContractTests/ProxyComparison) captures actual `Cratis.Arc.ProxyGenerator.Build` 22.45.0 output for equivalent command, snapshot-query, observable-query, nested/derived model, enum and validation fixtures. Both outputs compile against the pinned browser client and exercise routes, descriptors, hydration, validation and hook signatures. This is a compatibility check, not a byte-equality promise. Intentional differences include type-only imports, source enum member names (rather than .NET’s camel-cased names), formatting and provenance. The pinned .NET model-bound generator’s query-parameter sorting helpers are a [known defect](https://github.com/Cratis/Arc/issues/2998), not an intentional API difference: TypeScript follows the documented contract that `sortBy` names a read-model field. TypeScript retains but deprecates `sortBy` helpers for record, nested-model, array, map, and polymorphic fields until the [next major release](https://github.com/Cratis/Arc.TypeScript/blob/main/decisions/0004-defer-generated-proxy-removals-to-the-next-major-release.md): in-memory sorting rejects them, database providers apply their own ordering, and you should sort on a scalar field instead; scalar helpers (string, number, boolean, Date, Guid, DateOnly, TimeOnly, TimeSpan, enums, and concepts over those) are unchanged. The inventory separates intentional differences, known defects and known limitations. Regeneration rejects unreviewed bytes, normalizing only the generated header’s timestamp; offline checks also reject changed C# fixture or .NET option fingerprints. See the [contract-test guide](https://github.com/Cratis/Arc.TypeScript/blob/main/ContractTests/README.md#compare-proxy-generators) for the inventory, behavioral sorting checks and recapture instructions. ## Limits The analyzer keys generated models by namespace and class name, so two `Item` models in separate folders produce separate files, and generated references use aliased imports if those names collide in one file. An exported class marked `@identityDetailsProvider()` contributes its `detailsType` or concrete `provide()` result model without an HTTP endpoint. Source-only identity provider configuration outside the artifacts root is not analyzed. For client preferences, `@command({ treatWarningsAsErrors: true })` emits the command flag. `@query({ httpMethod: QueryHttpMethod.Query, treatWarningsAsErrors: true })` emits `setHttpMethod(QueryHttpMethod.Query)` and the query flag; import the enum from `@cratis/arc.core`. `Get` and `Auto` are also supported. These settings affect the generated client, not the server’s acceptance of requests. The HTTP server still caps `X-Allowed-Severity` at Warning. Dynamic decorator options cannot be emitted safely and fail generation. The output does not reproduce the .NET generator’s templates byte for byte: the file header and import layout differ. Nullable command types and interface-only model mode compile, but have not been compared against a live client. The [capability reference](/arc/backend/typescript/reference/capabilities/#proxies-introspection-and-tooling) tracks what is verified. ## Choose your next step 1. [Set up proxy generation](/arc/backend/typescript/proxy-generation/getting-started/) with proxies beside your backend slices; a separate frontend output folder remains an option. 2. [Use the proxies in React](/arc/backend/typescript/proxy-generation/frontend-usage/): commands, queries, paging, and live updates. 3. Look up [what the generator writes](/arc/backend/typescript/proxy-generation/generated-code/), [type mapping](/arc/backend/typescript/proxy-generation/type-mapping/), and [validation rules](/arc/backend/typescript/proxy-generation/validation/). 4. Adjust [configuration](/arc/backend/typescript/proxy-generation/configuration/) when your routes or folder layout differ from the defaults. For specialized output, see [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/), [file index tracking](/arc/backend/typescript/proxy-generation/file-index-tracking/), and the [low-level manifest](/arc/backend/typescript/proxy-generation/low-level-manifest/) for `defineCommand` and `defineQuery`. # Proxy generator configuration > Every arc-proxygenerator option for source analysis, route alignment, output layout, and import style, with defaults. `arc-proxygenerator` takes its settings on the command line. Options that take a value accept both `--option value` and `--option=value`, and reject a missing or empty value. `--help` prints the usage line. ## Required | Option | Meaning | | --- | --- | | `--project ` | The `tsconfig.json` of the project that declares your artifacts | | `--artifacts ` | The discovery root: only exported classes below it are considered | | `--output ` | An existing output directory; use the artifacts root to co-locate proxies by default | ## Route alignment Match these to the server’s [endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/), or generated clients call the wrong URL. | Option | Default | Server equivalent | | --- | --- | --- | | `--root-namespace ` | None | `discover(folder, { rootNamespace })`; also prefixes generated model folders | | `--segments-to-skip ` | `0` | `generatedApis.segmentsToSkipForRoute` | | `--api-prefix ` | `api` | `generatedApis.routePrefix` | | `--skip-command-name-in-route` | Off | `generatedApis.includeCommandNameInRoute: false` | | `--skip-query-name-in-route` | Off | `generatedApis.includeQueryNameInRoute: false` | **Changed for upgrades:** `--root-namespace App` now writes models under `App/...` rather than only changing their routes. For example, `Orders/Order.ts` becomes `App/Orders/Order.ts`; generation removes the old file unless `--skip-output-deletion` is set. Update direct model imports before regenerating. ## Output | Option | Default | Effect | | --- | --- | --- | | `--use-proxy-file-suffix` | Off for a dedicated output folder; required for co-located output | Name files `*.proxy.ts` instead of `*.ts` | | `--js-import-specifiers` | Off | Use `.js` extensions in local imports, for native Node ESM; extensionless imports suit Vite and other bundlers | | `--emit-interfaces` | Off | Emit undecorated interfaces instead of model classes; model constructors in proxies become `Object`, so choose this only when you do not need decorated model hydration | | `--skip-react-hooks` | Off | Emit server-only proxies: no `@cratis/arc.react` import and no static `use*`/`when` hooks on commands and queries, so a project that only needs the typed HTTP client does not need React installed. The generated classes still work with `@cratis/arc` directly. Programmatic option: `skipReactHooks` | | `--scalar-concept-subclasses` | Off | Type indirect and generic concept subclasses as their underlying value. See [Scalar typing of indirect concepts](#scalar-typing-of-indirect-concepts). Programmatic option: `scalarConceptSubclasses` | | `--skip-index-generation` | Off for a dedicated output folder; automatic for co-located output | Do not write `index.ts` barrels | | `--skip-output-deletion` | Off | Keep stale generated files instead of removing them | | `--metadata ` | Off | Generate server artifact metadata at the given absolute path and infer undecorated bindings | | `--use-generated-metadata` | Off | Infer the same bindings for client-only generation without publishing a metadata module | | `--check-metadata` | Off | Read-only check that a module passed with `--metadata` matches current source | | `--type-mapping =[#]` | None | Import a type from another package instead of generating it; repeat for several types. See [Map types to another package](#map-types-to-another-package) | | `--watch` | Off | Debounce edits under the artifacts root or in referenced local source files and regenerate; ignores co-located `*.proxy.ts` writes, a dedicated nested output folder, and the generated metadata module. Backend edits inside a co-located nested output folder trigger regeneration. Stdout reports `Watch ready` after the initial generation, source snapshot, and watcher registration, then `Watch change detected` when an edit schedules regeneration. See [Watch mode](#watch-mode) for notification recovery and polling costs | | `--watch-poll-interval ` | `1000` on macOS; `5000` elsewhere | Minimum delay between fallback source scans; `0` disables periodic scans. Requires `--watch`; accepts integers from `0` to `2147483647` | Output is co-located when it equals or contains the artifacts root, or when a nested output folder contains a discovered backend contributor: a command, read model, validator, resolved concept/model/enum, or class emitted in generated metadata (for example, `--artifacts src --output src/Features` with a validator in `Features`). An unrelated helper alone does not make a nested output co-located. Co-located output requires `--use-proxy-file-suffix` (generation fails without it) and writes no generated barrels. A handwritten `*.proxy.ts` collision fails before any files are published. Stale deletion affects only generator-owned files, not backend modules or components. Runtime discovery ignores the proxy suffix and `.tsx` components. A genuinely dedicated output folder nested inside the artifacts root (for example, `--artifacts src --output src/generated` with no analyzed backend contributors in `generated`) retains separate-output behavior: barrels are written by default, the suffix is optional, and generated files are excluded from artifact analysis and watch regeneration. If the server also discovers the artifacts root at runtime, place dedicated output outside that root or use `--use-proxy-file-suffix` so runtime discovery does not import generated proxy modules. ## Watch mode Native notifications naming a source file check only that file. Notifications for generated proxies, metadata, dedicated output, and unrelated files do not scan the tree. Recent files also get a content fingerprint so equal-sized edits on coarse-timestamp filesystems are not mistaken for duplicate notifications. A fallback scan discovers source additions, deletions, and edits missed by native notifications. It uses at most 32 concurrent filesystem operations. The next scan starts after a delay of at least `--watch-poll-interval` or four times the last scan’s duration, whichever is longer. Scans never overlap; large trees automatically scan less often. macOS defaults to 1,000 ms because directory notifications can be missed; other platforms default to 5,000 ms to retain recovery with lower idle overhead. Set `--watch-poll-interval 0` to rely entirely on native notifications after the initial snapshot. This saves periodic scanning but cannot recover missing or unnamed notifications. Programmatic watch configuration uses `SourceGeneratorOptions.watchPollInterval`; `generateFromSource()` remains a one-shot operation. Files and child directories that disappear, are temporarily busy, or cannot be read are skipped. Permission and other entry failures are reported at most once per path; a later scan can recover them. An unreadable or missing artifacts root ends watch mode, including during the initial snapshot. ## Map types to another package When several packages share a model, generate it once and import it everywhere else. A mapped type is not written to the proxy tree; every command, query, and model that references it imports it from the package instead. Route names, output layout, and every other default stay the same. ```bash arc-proxygenerator --project tsconfig.json --artifacts src --output src \ --type-mapping Shared.Money=@acme/money \ --type-mapping Shared.Amount=@acme/money#Money ``` The generated proxies then contain: ```typescript import { Money } from '@acme/money'; ``` The value is `=[#]`. Programmatic callers pass the same settings as `typeMappings` to `generateFromSource` and `analyzeSource`: ```typescript await generateFromSource({ project, artifacts, output, typeMappings: { 'Shared.Money': { package: '@acme/money' }, 'Shared.Amount': { package: '@acme/money', export: 'Money' } } }); ``` | Part | Meaning | | --- | --- | | Type | The type’s namespace-qualified name: the [root namespace](#route-alignment), then the folders between the artifacts root and its file, then the class or enum name. `Shared/Money.ts` under the artifacts root is `Shared.Money`. Segments to skip do not change it. The type is matched by its resolved declaration, not by how it is spelled at the use site, so an aliased import maps the same way | | `package` | The npm package, optionally with a subpath, such as `@acme/money` or `@acme/money/models` | | `export` | The symbol the package exports. Defaults to the type’s own name; set it when the package uses a different name | Behavior and limits: * A mapped class or enum works as a field, an array element, a command or query result, or a base class. A mapped enum keeps its primitive runtime constructor. * The package must export a class the generated code can use as a runtime constructor, decorated the way generated models are. It is imported as a value, including with `--emit-interfaces`. Install the package separately; the generator does not build or publish it. * Only types below the artifacts root can be matched. The fields of a mapped type are not analyzed. * Each type can be mapped once. A repeated `--type-mapping` for one type fails, and so does an invalid type name, package name, or export name. * Generation fails, naming the file, when a mapped export would share a name with another import or declaration in the same generated file, for example a mapped `Command` next to the `Command` imported from `@cratis/arc/commands`. Export the type from your package under a different name. * A mapping that matches no type reachable from the artifacts root is reported as a warning, since it is usually a misspelled name. ## Scalar typing of indirect concepts A concept that extends `ConceptAs` directly is always generated as its underlying value. A concept that extends another concept (`class DerivedName extends Name`) or a generic intermediate class (`class Shared extends ConceptAs`) is different. By default the generator emits an empty model class for it and types fields and parameters with that class. The wire carries only the value, so the empty class hydrates without it and the value is lost at runtime. `--scalar-concept-subclasses` types those concepts as their underlying value, the same as a direct concept: ```ts // Default @field(DerivedName) derivedName!: DerivedName; // With --scalar-concept-subclasses @field(String) derivedName!: string; ``` The empty classes are still emitted, marked `@deprecated`, so existing imports keep compiling. Turning the option on changes the types of generated fields and parameters, so code that assigns a `DerivedName` to a generated field, passes one as a command or query parameter, or uses `instanceof DerivedName` needs to use the underlying value instead. The option is off by default because changing generated proxy types is a breaking change. It becomes the default in the next major release, which also removes the empty classes; see [decision 0004](https://github.com/Cratis/Arc.TypeScript/blob/main/decisions/0004-defer-generated-proxy-removals-to-the-next-major-release.md). Turn it on now if your concepts inherit indirectly and you want their values to survive deserialization. ## Programmatic use The package also exports `analyzeSource`, `renderSource`, `renderGeneratedMetadata`, and `generateFromSource` for the same pipeline, with the `SourceGeneratorOptions` and `SourceRenderOptions` types. Programmatic `generateFromSource` accepts `metadata` or `generatedMetadata: true` for inference, and `scalarConceptSubclasses: true` for [scalar typing of indirect concepts](#scalar-typing-of-indirect-concepts). `renderSource` accepts a `recordedRules` override. The low-level manifest path exports `renderClientManifest` and `generateClient`; see [Low-level manifest](/arc/backend/typescript/proxy-generation/low-level-manifest/). ## Related * [Proxy generation](/arc/backend/typescript/proxy-generation/) * [File index tracking](/arc/backend/typescript/proxy-generation/file-index-tracking/) * [Generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/) # File index tracking > How the proxy generator marks the files it owns, keeps barrels and unchanged files stable, removes stale output, and protects hand-written files. You run the generator on every build, and it shares a folder with code people wrote by hand. It must replace what it owns, remove what no longer exists, and never touch anything else. It does that by marking every file it writes. ## Ownership headers Every generated file (and, with separate output, generated barrel) starts with a header naming its source, generation time, and a content hash: ```typescript // @generated by Cratis. Source: Tasks.Registration.RegisterTask. Time: 2026-09-24T20:35:55.3970000Z. Hash: FDB6670D1623B9F818509BA14942A78DF19A6EDEA92F42D7DD2D75CDB7D04895 ``` Never edit a file with this header; the next run replaces it. ## What a run changes | File | Behavior | | --- | --- | | An owned file whose content did not change | Kept byte for byte, with its timestamp | | An owned file whose content changed | Replaced | | An owned file for an artifact that no longer exists | Removed, unless `--skip-output-deletion` | | A hand-written file | Never overwritten or deleted | | A hand-written `index.ts` barrel in separate output | Never replaced; when stale owned files are deleted, only its exports of those files are removed. Directory and package re-exports stay | | A hand-written `index.ts` in the artifacts tree | Untouched in co-located mode; no barrels are written there | Co-located output skips barrels automatically. With separate output, `--skip-index-generation` stops the generator from writing them. ## Output folder safety The output root must exist. The generator resolves symbolic links in its ancestors, such as `/tmp` on macOS, to their real path, and refuses symbolic links *inside* the output. ## Related * [Configuration](/arc/backend/typescript/proxy-generation/configuration/) * [Low-level manifest](/arc/backend/typescript/proxy-generation/low-level-manifest/), which writes files with different rules # Use generated proxies in React > Execute generated commands, show live queries with Cratis Components, and pass query arguments from co-located React slices. The [Library sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Library) registers authors, lists them live, and shows each author’s books. Its React components sit beside the backend slices under `Features/`; `Web/src` holds the app shell. None of the components contains a URL, a `fetch` call, or a hand-written response type. The snippets below omit the license header and use `@cratis/arc` and `@cratis/arc.react` 22.45.0 with `@cratis/components` 4.23.0. ```text Features/Authors/Registration/ ├── Registration.ts ├── for_RegisterAuthor/when_registering/with_librarian_role.ts ├── RegisterAuthor.proxy.ts ├── RegisterAuthorForm.tsx └── for_RegisterAuthorForm/when_submitting/with_a_valid_name.tsx ``` `yarn test` runs backend `.ts` specs in Node and frontend `.tsx` specs in jsdom. The web tsconfig includes both the components and their specs; backend compilation and artifact discovery ignore frontend files and generated proxies. `Samples/Library/package.json` declares the frontend packages imported by the slices (`@cratis/components`, `@cratis/arc`, and `@cratis/arc.react`); `Web/package.json` declares its own app-shell dependencies. Vite 8 reads the nearest tsconfig for each proxy in `Features/`, not the web tsconfig, so `Web/vite.config.ts` sets `oxc: { decorator: { legacy: true } }`. Other bundlers reading slices outside the web project also need a legacy decorator transform. The web and frontend-test Vite configs deduplicate React, React DOM, Arc, Arc React, and Components across `Web/src` and `Features/`. Components 4’s built-in renderer does **not** require PrimeReact. ## Mount the providers and styles once Web/src/App.tsx ```tsx import { Arc } from '@cratis/arc.react'; import { CratisComponentsProvider } from '@cratis/components'; import { AuthorCatalog } from '../../Features/Authors/Listing/AuthorCatalog'; export function App() { return
CRATIS · ARC FOR TYPESCRIPT

The Library

Make room for a new story. Register an author, then fill their shelf.

; } ``` `Arc` configures requests, headers, and observable connections once. `CratisComponentsProvider` supplies component labels and notifications; it goes inside `Arc`, not instead of it. `Web/src/main.tsx` imports `reflect-metadata`, `@cratis/components/tokens`, `@cratis/components/styles`, and `@cratis/components/theme` once before mounting `App`. Vite proxies `/api` and `/.cratis` to the backend, so requests stay same-origin. ## Register an author from a co-located command dialog Features/Authors/Registration/RegisterAuthorForm.tsx ```tsx import { DialogResult, useDialogContext } from '@cratis/arc.react/dialogs'; import { CommandDialog } from '@cratis/components/CommandDialog'; import { InputTextField } from '@cratis/components/CommandForm'; import { Guid } from '@cratis/fundamentals'; import { RegisterAuthor } from './RegisterAuthor.proxy'; export function RegisterAuthorForm() { const { closeDialog } = useDialogContext(); return command={RegisterAuthor} title="Register an author" okLabel="Register author" initialValues={{ id: Guid.create() }} onBeforeExecute={command => { command.name = command.name.trim(); return command; }} onSuccess={() => closeDialog(DialogResult.Ok)} onCancel={() => closeDialog(DialogResult.Cancelled)}> value={command => command.name} title="Name" placeholder="e.g. Octavia Butler" /> ; } ``` The `InputTextField` accessor binds directly to the generated command property. The dialog runs validation and command execution and stays open on failure. Seed the required id with `initialValues` **before** validation; `onBeforeExecute` is only for the trim transform, after validation has enabled submit. An empty name fails client validation with the backend validator’s message, “An author name is required”; the server checks authorization, validation, and `handle()` again. `onSuccess` closes the dialog only after successful execution. The generated `RegisterAuthor.proxy.ts` is never hand-edited. ## Show an observable list and open the dialog Features/Authors/Listing/AuthorCatalog.tsx (excerpt) ```tsx const [selected, setSelected] = useState(null); const [RegistrationDialog, showRegistration] = useDialog(RegisterAuthorForm); return
setSelected(event.value)} detailsComponent={AuthorShelf}> { void showRegistration(); }} />
; ``` `AuthorCatalog.tsx` imports `DataPage`, `MenuItem`, and `Column` from `@cratis/components/DataPage`, `useDialog` from `@cratis/arc.react/dialogs`, and `AllAuthors` from `./AllAuthors.proxy`. `DataPage` subscribes through the generated observable query proxy and supplies paging, sorting, selection, and an Add toolbar action. `dataKey` identifies rows. The selected author is passed to `AuthorShelf`, which renders `AuthorBooks`; the latter subscribes to the co-located `BooksForAuthor` proxy with `{ authorId }`. The `catalog-page` container has a bounded height so the data table and details pane can size themselves. Neither the list nor the dialog needs a hand-rolled table, modal, or fetch. `allAuthors` returns an RxJS `Observable` on the server. Its proxy starts with an empty array and receives updates as the projection changes. The client multiplexes live queries over one connection. [Observable queries](/arc/backend/typescript/queries/observable-queries/) covers the server side, and the shared [React observable queries](/arc/frontend/react/queries/observable-queries/) page covers transport options. ## Use a snapshot when live updates are unnecessary `AuthorsPage.proxy.ts` in the same listing slice demonstrates a snapshot query. Its generated `useWithPaging(5, AuthorsPage.sortBy.name.ascending)` hook returns `[result, perform, setSorting, setPage, setPageSize]`, and `result.paging` contains page, size, total items, and total pages. For a Components page, pass `AuthorsPage` as the `DataPage` `query` prop instead of `AllAuthors`: `DataPage` selects the snapshot table and handles paging itself. A snapshot needs a refresh after a command; the observable `AllAuthors` used by the actual Library page updates automatically. The sample’s snapshot loads the small catalog before Arc pages it; do not copy that backend query for an unbounded production list. ## Follow individual changes An observable query returning a list also gets `useChangeStream(args?, getKey?, sorting?)`. It returns a `ChangeSet` with the items added, replaced, and removed since the last emission, instead of the whole list. Use it when a component animates or reconciles rows. See the shared [change stream](/arc/frontend/react/queries/change-stream/) page. For every generated hook signature, read [What the generator writes](/arc/backend/typescript/proxy-generation/generated-code/). The shared React pages go further into [commands](/arc/frontend/react/commands/react-usage/), [queries](/arc/frontend/react/queries/usage/), and [paging](/arc/frontend/react/queries/paging/). # Generate artifact metadata > Let Arc bind command and query parameters from TypeScript declarations without repeating class tokens in standard decorators. TypeScript erases parameter types when it compiles a standard decorator. Generate server metadata from your artifact sources to keep the method declaration as the source of truth. The Tasks sample builds both client proxies and server metadata from the same project: ```bash yarn tsc -b Source/Core Source/Tools/ProxyGenerator yarn workspace @cratis/arc.core.sample.tasks generate-proxies yarn build ``` The script passes `--project`, `--artifacts`, `--output`, and `--metadata Samples/Tasks/Features/generatedMetadata.ts` to `arc-proxygenerator`. The generated module is TypeScript, checked into the sample, and compiled along with the artifacts. `yarn build` checks its content against the current source and fails with a **regenerate artifact metadata** message when it is missing, changed, or stale. `yarn ci` checks the committed module before regenerating, so stale metadata fails CI. Run the generator before a `tsx` development server or Vitest run that uses the artifacts; do not rely on the test runner to discover erased types. Import the generated module and install it **before** discovery or `add()`, as in the [Tasks bootstrap](/arc/backend/typescript/getting-started/#see-what-started-the-server). The `builder.useGeneratedMetadata(metadata)` call must precede `builder.discover(...)`. Now the [sample command](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts) uses `handle(tasks: Tasks)` without `@inject(Tasks)`, and a read-model method can use `@query() static taskById(id: TaskId, tasks: Tasks)`. Arc analyzes argument names and positions, concrete service classes (including nullable services, which receive `null` when unregistered), nullable/optional fields, validator targets, and the declared observable return. A union of two concrete service classes needs an explicit token. Keep `@field(Type)` on every wire property: that is the Cratis runtime schema convention, not redundant generated metadata. `provide()` still supplies one unmarked value as the first `handle()` argument; typed `provided(Type)` remains available for explicitly bound additional values. For a development loop, run `arc-proxygenerator` with the same options plus `--watch`. It debounces changes under the artifacts root and watches referenced local source files outside that root. Run it in a separate terminal before your `tsx` or Vitest command; do not run a watched generator as a one-shot CI step. Use `--check-metadata` with the same arguments for a read-only build gate. The programmatic `generateFromSource({ project, artifacts, output, metadata })` writes the same module, while `renderGeneratedMetadata(project, artifacts, metadataFile)` returns its deterministic text without writing it. Paths in the programmatic generator are absolute, and the output folder must exist. Explicit decorators remain useful when you cannot run a build step: `@inject(Tasks)` and `@query(argument('id', TaskId), service(Tasks))` override inferred bindings. They also let you inject an interface by giving Arc a concrete class or `serviceToken` to resolve. The analyzer cannot invent a runtime value for an interface or erased type; it reports the file and line and asks for an explicit token. `@validator(Target)` similarly overrides inferred validator targets. Legacy `experimentalDecorators` with `emitDecoratorMetadata` still handles decorated class-valued parameters without generation; standard decorators need either generated metadata or explicit bindings. Bare `@query()` in standard mode does not give a TS1241 parameter-count error without generation; `build()` rejects unbound parameters instead. For commands, generated `handleResult` describes the **client response** after server-handled values are removed. When `handle()` returns events, operations, or a tuple with a handled value, `handleValueResult` separately retains the declared raw return cardinality for runtime validation; a returned event is not a client response. Regenerate both proxies and metadata together when changing command return types. The generated module records a version and each class’s runtime-checkable shape (fields, method names, and arities). `useGeneratedMetadata` rejects an incompatible version or shape at build time. **Reordering parameters without changing their count can silently bind the wrong dependency. Always run `--check-metadata` after editing an artifact, and retain class names when bundling (`keepNames` in esbuild).** Runtime reflection cannot see source-only changes such as parameter types or positions. Client-only generation without `--metadata` must opt into source-inferred bindings with `--use-generated-metadata` (or `generatedMetadata: true` programmatically); the presence of a generated file does not switch modes implicitly. Do not edit the generated module; regenerate it. The analyzer supports concrete exported class tokens, primitives, concepts, arrays of wire values, and declared query observable sources. This is a bounded source-analysis path, not full parity with the .NET Roslyn generators; see [capabilities](/arc/backend/typescript/reference/capabilities/). # What the generator writes > The structure of generated command classes, query classes, parameters interfaces, models, and barrels, and the exact React hook signatures each query shape receives. This page describes the generated files so you can read them, import from them, and predict what changes when the backend changes. Never edit them: the next run replaces any file that carries the `@generated by Cratis` header. The excerpts come from the Library and Tasks samples. ## Files and folders | Backend artifact | Generated file | | --- | --- | | `@command()` class `RegisterAuthor` in `Features/Authors/Registration/Registration.ts` | `Features/Authors/Registration/RegisterAuthor.proxy.ts` | | `@readModel()` class `Author` in `Features/Authors/Listing/Listing.ts` | `Features/Authors/Listing/Author.proxy.ts`, the model | | `@query()` method `allAuthors` on `Author` | `Features/Authors/Listing/AllAuthors.proxy.ts`, named after the method in PascalCase | | A `@field` model used by a command, query, or identity provider | A model file in its own namespace folder | | Separate output folder only | `index.ts`, exporting generated files; no barrels are emitted in co-located mode | Use `--artifacts Features --output Features --use-proxy-file-suffix` (absolute paths in a script) for the co-located layout. The suffix is required when the output is the artifacts folder or one of its ancestors, or a nested folder that contains backend artifacts (see [Configuration](/arc/backend/typescript/proxy-generation/configuration/)); with a dedicated output folder it is optional and files can end in `.ts`. Namespaces come from the discovery folder, or from an explicit `namespace` option, prefixed with `--root-namespace` when you set one. If the namespace or skipped segments change the generated path, check where it lands before importing it. ## Commands For each command the file holds an interface, a validator when the command has client-safe rules, and the command class: Authors/Registration/RegisterAuthor.proxy.ts (excerpt) ```typescript export interface IRegisterAuthor { id?: Guid; name?: string; } export class RegisterAuthorValidator extends CommandValidator { constructor() { super(); this.ruleFor(c => c.name).notEmpty().withMessage('An author name is required'); this.ruleFor(c => c.name).maxLength(100).withMessage('An author name cannot exceed 100 characters'); } } export class RegisterAuthor extends Command implements IRegisterAuthor { readonly route: string = '/api/authors/registration/register-author'; readonly validation: CommandValidator = new RegisterAuthorValidator(); readonly treatWarningsAsErrors: boolean = false; readonly roles: string[] = ['Librarian']; ``` | Member | Comes from | | --- | --- | | `I` interface | Every `@field` property, optional in the interface so you can fill it gradually | | `Validator` | The literal, unconditional rules of the command’s validators and of concept validators such as `AuthorNameValidator`; see [Validation rules](/arc/backend/typescript/proxy-generation/validation/) | | Base class | `Command` when `handle()` returns nothing for the client; `Command` when it returns a value, here the Tasks sample’s `TaskId` | | `route` | The server’s convention route, using the [route alignment](/arc/backend/typescript/proxy-generation/configuration/#route-alignment) options | | `roles` | `@roles(...)` on the command, for display decisions only | | `treatWarningsAsErrors` | `@command({ treatWarningsAsErrors: true })` | | Properties | A getter and setter per field, with change tracking | | `static use(initialValues?)` | Returns `[command, setValues, clearValues]` for React | Values that `handle()` returns for the server, such as Chronicle events or [command operations](/arc/backend/typescript/commands/operations/), are not part of the client response. [Type mapping](/arc/backend/typescript/proxy-generation/type-mapping/#return-types) lists each return shape. ## Queries Each query method becomes a class. A query with arguments also gets a parameters interface, named `Parameters` with no `I` prefix: Books/Listing/BooksForAuthor.proxy.ts (excerpt) ```typescript export interface BooksForAuthorParameters { authorId: Guid; } export class BooksForAuthor extends ObservableQueryFor { readonly route: string = '/api/books/listing/books-for-author'; readonly queryName: string = 'Books.Listing.Book.booksForAuthor'; readonly treatWarningsAsErrors: boolean = false; readonly roles: string[] = []; readonly defaultValue: Book[] = []; ``` | Member | Meaning | | --- | --- | | Base class | `QueryFor` for a snapshot, `ObservableQueryFor` when the method returns an observable source | | `queryName` | The fully qualified name the server uses for hub subscriptions | | `defaultValue` | What `result.data` holds before the first answer: `[]` for a list, `{} as TModel` for a single model | | `sortBy` | For list results, one sort helper per model field, as a static and an instance property; complex-field helpers are deprecated for removal in the next major release | | `parameterDescriptors`, `requiredRequestParameters` | The arguments, so the client can wait until required ones are set | | `validation` | A `QueryValidator` when the query’s arguments have client-safe rules | A query without arguments has no parameters interface, and its hooks take `sorting` as the first argument. ## Hook signatures `result` is a `QueryResultWithState` in every row. `args` appears only for queries with arguments. | Query shape | Hook | Returns | | --- | --- | --- | | Snapshot, single result | `use(args?)`, `useSuspense(args?)` | `[result, perform, setSorting]` | | Snapshot, list | `use(args?, sorting?)`, `useSuspense(args?, sorting?)` | `[result, perform, setSorting]` | | Snapshot, list | `useWithPaging(pageSize, args?, sorting?)`, `useSuspenseWithPaging(...)` | `[result, perform, setSorting, setPage, setPageSize]` | | Observable, single result | `use(args?)`, `useSuspense(args?)` | `[result]` | | Observable, list | `use(args?, sorting?)`, `useSuspense(args?, sorting?)` | `[result, setSorting]` | | Observable, list | `useWithPaging(pageSize, args?, sorting?)`, `useSuspenseWithPaging(...)` | `[result, setSorting, setPage, setPageSize]` | | Observable, list | `useChangeStream(args?, getKey?, sorting?)` | `ChangeSet` with `added`, `replaced`, and `removed` | | Any | `when(condition)` | A builder with the same hooks, enabled only while `condition` is true | Only list results get paging, sorting helpers, and change streams. That is a client API rule; the server decides how it pages. See [Paging](/arc/backend/typescript/queries/model-bound/paging/). ## Models A read model or nested `@field` class becomes a class with the same `@field` declarations, so the client deserializes JSON into real types: Authors/Listing/Author.proxy.ts (excerpt) ```typescript export class Author { @field(Guid) id!: Guid; @field(String) name!: string; } ``` The server declares `@field(AuthorId) id` and `@field(AuthorName) name`. Concepts arrive as their underlying types, here `Guid` and `string`, because the wire carries only the value. A concept that inherits indirectly, such as `class DerivedName extends AuthorName`, or through a generic intermediate class keeps its empty model class unless you turn on [`--scalar-concept-subclasses`](/arc/backend/typescript/proxy-generation/configuration/#scalar-typing-of-indirect-concepts). With `--emit-interfaces` the generator writes interfaces instead, which carry no runtime metadata. The identity provider’s `detailsType` is generated the same way, for [`useIdentity`](/arc/backend/typescript/identity/frontend/). ## Related * [Use generated proxies in React](/arc/backend/typescript/proxy-generation/frontend-usage/) * [Type mapping](/arc/backend/typescript/proxy-generation/type-mapping/) * [Validation rules](/arc/backend/typescript/proxy-generation/validation/) * [File index tracking](/arc/backend/typescript/proxy-generation/file-index-tracking/) # Set up proxy generation > Generate browser proxies beside backend slices, install the client packages, and keep generation repeatable. Start with an Arc backend that already has [commands](/arc/backend/typescript/commands/) or [queries](/arc/backend/typescript/queries/), and a React frontend beside it. By the end of this guide each slice holds its backend module, typed proxies, and React component; the frontend compiler checks every call against the backend’s declarations. The steps follow the [Library sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/README.md): a backend in `Samples/Library/Features` and a Vite frontend in `Samples/Library/Web`. ## Build the generator `@cratis/arc.proxygenerator` is not published to npm. Build it from a clone of this repository: ```sh yarn install --immutable yarn build ``` The CLI is then `Source/Tools/ProxyGenerator/dist/cli.js`. Run it with `node`. For a backend outside the clone, install the packed generator as a development dependency instead; [Create an application](/arc/backend/typescript/getting-started/create-an-application/#generate-the-metadata) shows the install and a matching script. ## Keep the proxy beside the slice Use the same existing `Features` folder for `--artifacts` and `--output`. The generator requires `--use-proxy-file-suffix` in this mode so proxies cannot take the names of backend modules. It writes no `index.ts` barrels in the artifacts tree. On every run it replaces files it owns, removes stale owned files, and leaves handwritten files alone; collisions with handwritten files fail before writing. [File index tracking](/arc/backend/typescript/proxy-generation/file-index-tracking/) explains how it recognizes owned files. The output folder must exist before the first run. ## Run the generator from a script A small script keeps the options in one place. This is the Library sample’s [`generate-proxies.mjs`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/generate-proxies.mjs): generate-proxies.mjs ```javascript import { execFileSync } from 'node:child_process'; import process from 'node:process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = dirname(fileURLToPath(import.meta.url)); execFileSync(process.execPath, [join(root, '../../Source/Tools/ProxyGenerator/dist/cli.js'), '--project', join(root, 'tsconfig.json'), '--artifacts', join(root, 'Features'), '--output', join(root, 'Features'), '--metadata', join(root, 'Features/generatedMetadata.ts'), '--use-proxy-file-suffix'], { stdio: 'inherit' }); ``` The CLI rejects relative paths, so the script builds every path from its own location. | Option | Why it is here | | --- | --- | | `--project` | The backend’s `tsconfig.json`, so the compiler API resolves your imports and decorators | | `--artifacts` | The same folder the backend passes to `builder.discover(...)` | | `--output` | The existing `Features` folder: generated proxies sit beside the backend modules | | `--metadata` | Also writes the server metadata module that `main.ts` registers with `useGeneratedMetadata`. Use `--use-generated-metadata` instead when you only want client output | | `--use-proxy-file-suffix` | Required when the output is co-located with backend artifacts (the artifacts folder, an ancestor of it, or a nested folder containing artifacts; see [Configuration](/arc/backend/typescript/proxy-generation/configuration/)): prevents generated names from colliding with backend modules | Run it with `yarn workspace @cratis/arc.sample.library generate-proxies`. Each run reports how many files it changed. A run with nothing to change prints `Generated 0 changed file(s)` and leaves every file untouched, timestamps included. If your server changes route options, such as `generatedApis.routePrefix` or a `rootNamespace` for discovery, pass the matching [route alignment](/arc/backend/typescript/proxy-generation/configuration/#route-alignment) options too. Otherwise the proxies call URLs the server does not serve. ## Look at the result The output mirrors the backend’s namespaces, one proxy per operation and model, with no generated barrels: ```text Features/Authors/Registration/ ├── Registration.ts ├── RegisterAuthor.proxy.ts └── RegisterAuthorForm.tsx Features/Authors/Listing/ ├── Listing.ts ├── AllAuthors.proxy.ts ├── Author.proxy.ts ├── AuthorsPage.proxy.ts └── AuthorCatalog.tsx ``` Do not give a `.tsx` component the same basename as a `.ts` backend file. Name it for what it renders, as `RegisterAuthorForm.tsx` does. `AllAuthors` and `AuthorsPage` are static query methods on the `Author` read model. Each query method gets its own file, named after the method, in the read model’s folder. ## Install the frontend packages The generated files and React components live in `Features/`, not `Web/`. Install their client dependencies in the package that owns `Features/` (or a common ancestor/workspace root that resolves imports from `Features/`): ```sh cd path/to/package-containing-Features npm install @cratis/arc@22.45.0 @cratis/arc.react@22.45.0 @cratis/fundamentals react react-dom reflect-metadata # If your slices use Cratis Components: npm install @cratis/components@4.23.0 ``` Also declare the dependencies imported by the web app in its own package. Installing packages only in a sibling `Web/node_modules` does **not** make them resolvable from `Features/`; including the slices in `Web/tsconfig.json` does not change that. The Library sample declares these dependencies in [`Samples/Library/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/package.json) for its slices and in [`Web/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Web/package.json) for its app shell. Generated commands and queries import both `@cratis/arc` and the React hooks from `@cratis/arc.react`, even when you only use the classes. Models use `@field` from `@cratis/fundamentals`. `@cratis/arc.react` accepts React 18 or 19. Import `reflect-metadata` once, before anything else, in the frontend’s entry point, as the Library sample’s `main.tsx` does. Compile the frontend in `Bundler` module resolution with `experimentalDecorators: true`. The backend tsconfig excludes `**/*.proxy.ts` and `**/*.tsx`; the web tsconfig includes them under `../Features`, without including the backend `.ts` modules. Allow Vite to read the slice folder outside `Web/` and deduplicate packages imported from both locations. Vite 8’s Oxc transform reads the nearest tsconfig **per file**: a proxy in `Features/` does not inherit `Web/tsconfig.json`’s `experimentalDecorators`. Configure Vite explicitly for the proxies’ legacy decorator mode, as in the Library sample’s [`vite.config.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Web/vite.config.ts): Web/vite.config.ts (excerpt) ```typescript export default defineConfig({ oxc: { decorator: { legacy: true } }, resolve: { dedupe: ['react', 'react-dom', '@cratis/arc', '@cratis/arc.react', '@cratis/fundamentals', '@cratis/components'] }, server: { fs: { allow: [searchForWorkspaceRoot(process.cwd()), slices] } } }); ``` Here `slices` is the absolute path to `Features/` (see the linked config); omit `@cratis/components` from the dedupe list if your slices do not use it. Other bundlers reading slices outside the web project likewise need a legacy decorator transform; checking only the web tsconfig does not ensure the browser can parse the bundle. ## Check it compiles Compile the frontend with its own type check. For the Library sample: ```sh yarn workspace @cratis/arc.sample.library.web build ``` The Library build also checks that the production JavaScript parses and the Vite dev transform lowers decorators in a co-located proxy. This second checkpoint catches a missing package, a decorator setting, or an import path that does not match the generated folders. A type error that points into a generated file almost always means a package version or compiler setting differs from the ones above, since generated files are never edited by hand. ## Keep generation repeatable * **Commit or regenerate, and pick one.** Library and Tasks commit their co-located proxies, so a frontend can build without running backend tooling. Unchanged runs keep files byte for byte, so committed output only changes when the source does. * **Regenerate with the backend.** Run the script whenever a command, query, model, or validator changes. During development, add `--watch` to the same options in a separate terminal. * **Gate stale server metadata.** When you use `--metadata`, run the generator with `--check-metadata` in CI; it fails when the committed module no longer matches the source. ## Separate output folders For a frontend in another repository, or if its build cannot read the backend’s slice tree, set `--output` to an existing dedicated folder such as `Web/src/generated`. This mode keeps its per-directory `index.ts` barrels by default and does not require `--use-proxy-file-suffix` (you can still opt in). Include that folder in the frontend tsconfig, and exclude it from the backend build. The generator still deletes only files it owns; do not point it at the frontend’s handwritten `src` root without reviewing existing files. Next, [use the proxies in React](/arc/backend/typescript/proxy-generation/frontend-usage/). # Low-level manifest > Generate proxies for defineCommand and defineQuery definitions from an explicit JSON client manifest, and know its bounded contract. Low-level `defineCommand` and `defineQuery` definitions have Zod schemas, not decorated classes, so the source analyzer cannot see their output types. For them, you declare the output explicitly with `clientOutput`, export a **version 1 JSON manifest** from the registered `ArcServer`, and feed it to the older positional CLI form. That CLI reads JSON only; it never imports your application. ## Declare explicit output shapes Create `export-clients.mjs` in a project that depends on `@cratis/arc.core` and `zod`. Install the Arc package from a packed tarball or use it inside the clone’s workspace; see [Create an application](/arc/backend/typescript/getting-started/create-an-application/). ```js import { writeFile } from 'node:fs/promises'; import { z } from 'zod'; import { ArcServer, defineCommand, defineQuery, exportClientManifest } from '@cratis/arc.core'; const widget = { kind: 'dto', name: 'Widget', fields: [ { name: 'id', type: { kind: 'string' } }, { name: 'name', type: { kind: 'string' } } ] }; const items = []; const server = new ArcServer({ commands: [defineCommand({ name: 'CreateWidget', namespace: 'Sales', schema: z.object({ name: z.string(), note: z.string().optional() }), clientOutput: { output: widget }, handle: ({ name }) => { const item = { id: String(items.length + 1), name }; items.push(item); return item; } })], queries: [defineQuery({ name: 'GetWidgets', namespace: 'Sales', path: '/v1/widgets/search', schema: z.object({ term: z.string() }), clientOutput: { output: { kind: 'array', element: widget } }, perform: ({ term }) => items.filter(item => item.name.includes(term)) })] }); await writeFile('client-manifest.json', JSON.stringify(exportClientManifest(server), null, 2)); await server.dispose(); ``` Then, from the repository root: ```sh yarn install --immutable yarn build mkdir -p my-app/frontend/src/generated node export-clients.mjs node Source/Tools/ProxyGenerator/dist/cli.js "$PWD/client-manifest.json" "$PWD/my-app/frontend/src/generated" ``` The exported routes come from the server’s resolved operation graph, including namespace skipping and custom paths. Export does not call handlers, validators, `provide`, service factories, or authorization callbacks. Input Zod defaults and refinements are rejected before JSON Schema conversion could evaluate a default factory. A model-bound `@command()` or `@readModel()` has no `clientOutput`, so the manifest path rejects an application that registers one; use the [source analyzer](/arc/backend/typescript/proxy-generation/) for those. ## Use the generated proxies Compile the emitted `.proxy.ts` files in a strict ESNext/Bundler frontend against `@cratis/arc` 22.45.0, `@cratis/fundamentals` 7.22.0, and `rxjs` 7.8.2, the versions the client contract tests pin. Set a **real listening server origin** on each instance: ```js import { Sales_CreateWidget } from './generated/Sales_CreateWidget.proxy.js'; import { Sales_GetWidgets } from './generated/Sales_GetWidgets.proxy.js'; const origin = 'http://127.0.0.1:3000'; const command = new Sales_CreateWidget(); command.setOrigin(origin); command.name = 'Ada'; const preflight = await command.validate(); if (preflight.isSuccess) { const saved = await command.execute(); console.log(saved.response?.name); } const query = new Sales_GetWidgets(); query.setOrigin(origin); const found = await query.perform({ term: 'Ada' }); console.log(found.data.map(widget => widget.name)); ``` `validate()` never invokes the handler. Roles copied into proxies are informational; the server still enforces authorization. Command and query results are validated against the declared output before a successful result is sent; a mismatch fails under the host’s usual exception privacy rules. Observable definitions with `clientOutput` generate `ObservableQueryFor` proxies with the exact query name. ## The bounded contract | Supported | Rejected | | --- | --- | | Closed Zod object inputs with strings, booleans, string enums, arrays of these, optional fields, and safe-integer `z.number().safe()` fields | Missing metadata, unsupported Zod constraints, defaults, refinements, and transforms | | Command outputs: explicit `void`, string, boolean, safe number, string enum, flat DTOs, homogeneous arrays | Nullable command fields, nested DTOs, open schemas, polymorphic or recursive types | | Query outputs: DTOs or arrays | Scalar query outputs | | Safe fixed names and routes | Placeholders, duplicate or case-colliding IDs and files | * Query input names `page`, `pageSize`, `sortBy`, and `sortDirection` (case-insensitive) are reserved and rejected. * Empty manifests and sanitized manifests over 1 MiB are rejected; the CLI caps input JSON at 4 MiB. * The published client loses scalar `false`, `0`, and `''` query data and cannot serialize explicit null command fields. Optional omission is supported; explicit null is not. * Optional DTO array fields omitted on the wire can be initialized to `[]` by the published Fundamentals client, and required empty query arrays can be omitted by the GET binder. * This path generates no React hooks, shared validation rules, or type reflection. ## How the CLI writes files The CLI needs absolute paths for the manifest and an existing, real output directory. It validates every descriptor before writing, refuses symbolic-link escapes and hand-written files, keeps unchanged bytes, and publishes through temporary sibling files with atomic no-clobber links for new files and renames for owned replacements. New files receive the normal creation mode; replacements keep their mode. It is not adversarial race-free directory locking. On a partial failure, the error lists the paths already committed, nothing is rolled back, and the exit status is nonzero. It never deletes stale files; remove obsolete proxies yourself. ## Related * [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/) * [Proxy generation](/arc/backend/typescript/proxy-generation/) # Type mapping > Which TypeScript declarations the proxy generator recognizes, which client types they become, and which shapes fail with a diagnostic. The generator reads your source, not your running server, so it only knows what the declarations say. This page lists what it recognizes and what becomes of each type in the generated client. ## What is scanned The generator shares Arc’s discovery walk: only exported classes under `--artifacts` are considered, and `index.ts`, `given/`, `dist/`, `node_modules/`, `for_*/`, and symbolic links are skipped. Decorators and Fundamentals types are recognized by resolved symbol and declaring package, not by how you spell the import path. ## Types | Server declaration | Generated client type | | --- | --- | | `String`, `Number`, `Boolean` fields | `string`, `number`, `boolean` | | `Date` | `Date` | | Fundamentals `Guid`, `DateOnly`, `TimeOnly`, `TimeSpan` | The same Fundamentals types | | `ConceptAs` | Its underlying type; `TaskId extends ConceptAs` becomes `Guid`. A concept that inherits through another concept or a generic intermediate class becomes its underlying type with [`--scalar-concept-subclasses`](/arc/backend/typescript/proxy-generation/configuration/#scalar-typing-of-indirect-concepts) | | Decorated model classes | Generated model classes, or interfaces with `--emit-interfaces` | | Arrays | Arrays of the element type | | String and number enums, string-literal unions | Enums and unions | | Fundamentals `@derivedType('id')` classes and their declared bases | Emitted when found under the artifacts root, with the same `@derivedType` declaration | ## Return types | Query or command returns | Generated as | | --- | --- | | A value, or `Promise` | The value type | | `QueryPage` | A paged query of `T` | | RxJS `Observable`, `Subject`, `BehaviorSubject`, `ReplaySubject`, `ObservableSource`, or `AsyncIterable` | An observable query of `T`; RxJS types must resolve to symbols declared by the `rxjs` package | | An array | An enumerable result; the generator emits the array generic that matches the runtime constructor | | A command returning a Chronicle `@eventType()` value or array, `eventForEventSourceId(...)`, `EventsWithConcurrencyScopes`, `AggregateRootCommitResult`, or Arc `CommandOperation(s)` | No client response (`Command`); these values are consumed on the server | | A command returning `eventSourceIdResponse(id)` | The id value type (currently `string`); the Chronicle handler replaces the wrapper with the id when appending events | | A command returning `tuple(...)` / `ArcTuple` | The single non-handled element, or no response if all elements are handled; multiple non-handled elements fail generation | | A command returning `Promise` or a union of handled and one visible type | Unwrap the promise and select the visible type | ## Field behavior `@optional()`, `@nullable()`, `@defaultValue()`, and `@enumeration()` decide how a field is generated. A required nullable field is `T | null`; sending an explicit `null` command field with the published client has not been verified end to end. An undecorated `?` cannot make a server-required field optional and fails with a diagnostic. Query arguments need explicit `@query(argument(...))` descriptors. Standard-mode `@inject()` and a bare `@query()` still need explicit tokens and descriptors. ## Diagnostics An unsupported result type, multiple unhandled tuple values, ambiguous visible union alternatives, a bare array of command operations, or an unbound query parameter fails with a file and line location instead of falling back to `any`. Event detection resolves Chronicle’s decorator symbol, so a locally defined decorator named `eventType` does not make a class server-handled. Ordinary arrays are not tuples; only arrays entirely of decorated events are omitted. Model identities include namespace and class name; the generator emits same-named models from different folders to their respective namespace folders. Two models that resolve to the same namespace and name still fail with `Ambiguous model name`. Import aliases for two different models with the same class name in one generated file are not supported. ## Related * [Concepts](/arc/backend/typescript/concepts/) for the server-side wire types * [Validation rules](/arc/backend/typescript/proxy-generation/validation/) # Validation rules in proxies > Which @validator rules the proxy generator copies into client validators, which stay on the server, and why the server still checks every request. A frontend form should show “A title is required” before the user presses submit. The generator copies the rules it can evaluate safely in the browser from your server validators into the generated proxies. The server still validates every request, so a skipped client rule never weakens the contract. ## What is copied The generator reads literal, unconditional `ruleFor` chains in the constructors of `@validator(Target)` classes and emits them for: * commands, including rules on direct concept fields; * query arguments, when the query declares an explicit `argumentsModel`. For the Tasks sample, the generated `RegisterTaskValidator` contains: ```typescript this.ruleFor(c => c.title).notEmpty().withMessage('A title is required'); this.ruleFor(c => c.title).maxLength(100).withMessage('A title can have at most 100 characters'); ``` The client-safe rules are `notNull`, `notEmpty`, `minLength`, `maxLength`, `length`, `emailAddress`, `phone`, `url`, `matches`, `greaterThan`, `greaterThanOrEqual`, `lessThan`, and `lessThanOrEqual`, with their messages. ## What stays on the server Conditions (`when`, `unless`), predicates (`must`, `mustAsync`), and any rule that needs runtime evaluation stay on the server and produce a source diagnostic, so you know the client will not check them. The `TaskTitleValidator` concept rule in the sample uses `must` and is therefore server-only. ## Related * [Command validation](/arc/backend/typescript/commands/command-validation/) * [Query validation](/arc/backend/typescript/queries/validation/) # Queries > Read application state through read models and their static query methods, snapshot or live, and find the pages for arguments, paging, validation, and transports. A frontend needs lists, lookups, and live views, and each one normally means a route, argument parsing, paging code, and a response shape to keep in step with the client. In Arc, a query is a static method on a read model. Arc supplies the route, binds the arguments, pages and sorts the result, and, for a live query, streams updates to every subscriber. ``` GET or QUERY Client Arc route Authentication, authorization, validation Query method Renderers, interceptors, paging and sorting QueryResult ``` ## A query ```typescript @readModel() export class TaskItem { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; @query(service(Tasks)) static allTasks(tasks: Tasks): TaskItem[] { return tasks.all(); } } ``` This excerpt is from the [Tasks sample read model](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Listing/Listing.ts). `GET /api/tasks/listing/all-tasks` answers with a query result whose `data` holds the tasks. A read model here is a class describing the shape of what you return. Where the data comes from is your choice: an in-memory service, [MongoDB](/arc/backend/typescript/mongodb/), [SQL with Drizzle](/arc/backend/typescript/sql/), or a Chronicle projection. ## Find your way | Page | Use it when you want to | | --- | --- | | [Model-bound queries](/arc/backend/typescript/queries/model-bound/) | Declare read models, query methods, and their parameters | | [Query arguments](/arc/backend/typescript/queries/model-bound/query-arguments/) | Bind named, optional, and repeated arguments from the query string | | [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/) | Page and sort in memory, or return a page your data source cut | | [Query validation](/arc/backend/typescript/queries/validation/) | Validate arguments together with `QueryValidator` | | [Query pipeline](/arc/backend/typescript/queries/query-pipeline/) | Understand the order of checks and result processing | | [Using the HTTP QUERY method](/arc/backend/typescript/queries/using-the-http-query-method/) | Send structured arguments in a body | | [Observable queries](/arc/backend/typescript/queries/observable-queries/) | Serve a snapshot and live updates from the same route | | [Subscribe to an observable query](/arc/backend/typescript/queries/subscribing-to-observable-queries/) | Receive updates over SSE, a WebSocket, or the `@cratis/arc` client | | [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/) | Share one WebSocket or SSE connection across many subscriptions | | [Observable emission guards](/arc/backend/typescript/queries/observable-query-emission-guards/) | Re-check access for every emission | | [Query health](/arc/backend/typescript/queries/query-health/) | Inspect the caller’s own hub connections | | [Using observable queries with curl](/arc/backend/typescript/queries/using-observable-queries-with-curl/) | Explore a live query from a terminal | | [Query renderers](/arc/backend/typescript/queries/renderers/) | Turn a provider-owned value into data or a page | | [Read-model interception](/arc/backend/typescript/queries/read-model-interception/) | Transform read-model instances before they reach the wire | # Model-bound queries > Put static query methods on a read-model class, bind their arguments and services, and know which return types Arc accepts, how it awaits them, and what absence looks like. A task list, a lookup by ID, and a live board all read the same kind of data. Written as separate routes, each needs its own argument parsing, its own “not found” convention, and its own response shape. In Arc you put these reads on the read model they return, as static methods. Each `@query()` method becomes a route, its parameters are bound from the request or resolved as services, and every answer uses the same `QueryResult` envelope. ## Declare a read model and its queries The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Listing/Listing.ts) exposes a list, a lookup, and a live list: Features/Tasks/Listing/Listing.ts ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel, service } from '@cratis/arc.core'; import type { BehaviorSubject } from 'rxjs'; import { TaskId } from '../TaskId.js'; import { TaskTitle } from '../TaskTitle.js'; import { Tasks } from '../Tasks.js'; @readModel() export class TaskItem { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle; @query(service(Tasks)) static allTasks(tasks: Tasks): TaskItem[] { return tasks.all(); } @query() static taskById(id: TaskId, tasks: Tasks): TaskItem | undefined { return tasks.byId(id); } @query() static observeAllTasks(tasks: Tasks): BehaviorSubject { return tasks.observeAll(); } } ``` With the sample running, `GET /api/tasks/listing/all-tasks` returns the list, and `GET /api/tasks/listing/task-by-id?id=` returns one task. The `@field` declarations describe the shape each query returns; Arc encodes it on the way out, and the [proxy generator](/arc/backend/typescript/proxy-generation/) uses the same declarations for the frontend model. ## What makes a method a query Arc serves a method when all three hold: * the class is marked `@readModel()`, * the method is `static`, * the method is marked `@query(...)`. Any other static method on the class is an ordinary helper and gets no route. That lets you keep shared filtering or mapping code next to the queries without exposing it. Arc rejects the declarations that would otherwise be silently ignored. `@query()` on an instance or private method throws as soon as the class is loaded, and `@roles`, `@authorize`, `@allowAnonymous`, or `@path` on a static method without `@query()` fails at `build()`. ## Bind every parameter Each parameter is one of three kinds: | Descriptor | Binds | | --- | --- | | `argument(name, Type, options?)` | A named argument from the query string or `QUERY` body; see [Query arguments](/arc/backend/typescript/queries/model-bound/query-arguments/) | | `service(Token)` | A service from the execution scope; see [Dependency injection](/arc/backend/typescript/dependency-injection/) | | `queryOptions()` | The request’s paging and sorting; see [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/) | Standard decorators cannot see parameter types, so Arc needs to learn them somewhere: * **With [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/)**, as the sample uses, `@query()` is enough. The generator reads the source: a primitive, concept, or array of them becomes a named argument, and a concrete class becomes a service. That is how `taskById(id: TaskId, tasks: Tasks)` binds `id` from the query string and `tasks` from the scope. * **Without it**, list one descriptor per parameter, in the same order as the method signature: `@query(argument('id', TaskId), service(Tasks))`. `allTasks` shows this form; explicit descriptors always win over generated ones. A mismatch fails early. TypeScript error TS1241 on a `@query(...)` usually means the descriptors do not match the parameters, and a parameter nobody describes fails at `build()` with `Unbound parameters`. See [Troubleshooting](/arc/backend/typescript/troubleshooting/#ts1241-unable-to-resolve-signature-of-method-decorator). Services resolve from a fresh scope per request, or per subscription for an observable query. A scoped service is never shared between two callers. ## Return what the caller should see A query method returns data, and Arc wraps it: | The method returns | The caller gets | | --- | --- | | An array of the model | `data` is the array. Arc [pages and sorts it in memory](/arc/backend/typescript/queries/model-bound/paging/) when the request asks | | One model | `data` is the object | | `undefined` or `null` | A successful result with no `data` property | | `queryPage(items, totalItems)` | `data` is `items`, and `paging` reports your totals; see [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/#return-a-page-your-data-source-cut) | | A value a registered [renderer](/arc/backend/typescript/queries/renderers/) accepts | Whatever the renderer produces, such as a database-side page | | An observable source | A live query; see [Declare observable queries](#declare-observable-queries) | A method can be `async` or return a promise of any of these. Arc awaits it before rendering, so `static async byId(...): Promise` behaves exactly like its synchronous version. The same holds for an observable query: an `async` method that awaits setup work and then returns a source is fine. Decorated models and concepts are encoded to their wire shape, so a `TaskId` goes out as a UUID string. ## Absence is an answer, not an error `taskById` returns `undefined` when no task has that ID. The caller gets HTTP 200, `isSuccess: true`, and no `data` property. An empty array is a successful empty list. Arc does not turn absence into a 404, because a query that found nothing did its job. A thrown error is different. It becomes a failed result with `hasExceptions: true` and status 500, and the message is replaced unless you enable exception details. Let storage failures throw; never catch them and return `undefined` or `[]`, or the caller cannot tell “no task” from “the database is down”. With generated metadata, Arc also checks the value against the declared return type. A method declared as `TaskItem` that returns `undefined`, or one declared as `TaskItem[]` that returns a single object, fails with an exception instead of sending the caller a shape its generated client does not expect. Declare `TaskItem | undefined` when absence is possible. ## Declare observable queries A query that returns a live source serves a snapshot on GET and streams changes to subscribers. With generated metadata, Arc infers this from the declared return type, as it does for `observeAllTasks`. Without it, add `{ observable: true }`: `@query({ observable: true }, service(Tasks))`. Arc needs to know before registration so snapshots, server-sent events, WebSocket admission, introspection, and generated clients agree on the contract. A snapshot query that returns a live source anyway fails at run time. Arc releases through the first hook on the returned object: `Symbol.asyncDispose`, `Symbol.dispose`, `dispose()`, `close()`, or `return()` when the object itself has `next()` and `return()`. Arc never releases a service-container-owned value; its owning scope or registry handles disposal. It never subscribes or calls `[Symbol.asyncIterator]()` or `[Symbol.iterator]()` during cleanup. A cold RxJS Observable or shared Subject without a release hook is rejected but left untouched; release failures (including timeouts) are reported alongside the rejection even if the request is canceled. A release that fails after the deadline is logged. Use an RxJS `BehaviorSubject` when there is always a current value, and `Subject` or `Observable` when there may not be one yet. [Observable queries](/arc/backend/typescript/queries/observable-queries/) covers sources, paging, and authorization, and [Subscribe to an observable query](/arc/backend/typescript/queries/subscribing-to-observable-queries/) covers the transports. ## Routes and identity By default the route is `/api//`: `TaskItem.allTasks` lives at `/api/tasks/listing/all-tasks`. Its identity is `Tasks.Listing.TaskItem.allTasks`, including the read-model class. `@path('/api/custom-path')` on a query method overrides the class path. See [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/). ## Authorization `@roles`, `@authorize`, and `@allowAnonymous` work on the read-model class and on query methods. An explicit method declaration replaces the class declaration; without one, the method inherits it. For example, class `@allowAnonymous()` does not bypass method `@roles('Reader')`. A denied caller never reaches the method and gets `isAuthorized: false`. A role says who may call the query, not which rows they may see; see [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/#queries-roles-and-ownership). ## Recap A read model owns its queries as static `@query()` methods. Generated metadata, or explicit descriptors, tell Arc which parameters are arguments and which are services. Return the data, sync or async; return nothing for absence and throw for failure; return a source for a live query. Arc supplies the route, the envelope, and paging. ## Next step [Query arguments](/arc/backend/typescript/queries/model-bound/query-arguments/) covers optional, array, and concept arguments. Then [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/) shows how to cut a page in your data source instead of in memory. # Paging and sorting > Page and sort array results in memory, receive paging options in a query, and return a page your data source has already cut with queryPage. A client rarely wants every row. It asks for one page, in an order. Arc handles that for array results without any code from you, and lets a query that talks to a database cut the page itself. ## Page and sort arrays automatically When a query returns an array, Arc sorts it, then pages it: Product.ts ```typescript import { field } from '@cratis/fundamentals'; import { query, queryOptions, queryPage, readModel, service, type QueryOptions } from '@cratis/arc.core'; export class Catalog { readonly items = [{ name: 'Apple' }, { name: 'Pear' }, { name: 'Fig' }]; } @readModel() export class Product { @field(String) name!: string; @query(service(Catalog)) static all(catalog: Catalog): Product[] { return catalog.items; } @query(service(Catalog), queryOptions()) static recent(catalog: Catalog, options: QueryOptions) { const page = options.paging?.page ?? 0; const size = options.paging?.pageSize ?? 0; const offset = Math.min(2_147_483_647, page * size); const items = size ? catalog.items.slice(offset, offset + size) : catalog.items; return queryPage(items, catalog.items.length); } } ``` With `Catalog` registered as a singleton, `GET /api/all?pageSize=2&sortBy=name&sortDirection=desc` returns `Pear` and `Fig`, with `paging` reporting `{"page":0,"size":2,"totalItems":3,"totalPages":2}`. ## Request parameters | Request | Meaning | | --- | --- | | GET `pageSize` of 1 or more | Page `page` (zero-based, default 0) of that size | | GET `pageSize` of 0 or negative, or `page` negative with any integer size | 400 with a paging rule (`Size` or `Page`); both invalid values report `Page` then `Size` | | GET nonnumeric or out-of-int32 `page`/`pageSize` | Defaults to page 0 or unpaged, respectively; a negative `page` is ignored if `pageSize` cannot be parsed. Leading zeros, `+`, and ASCII integer whitespace are accepted; nonbreaking spaces are not | | `QUERY` `paging.pageSize` of 1 or more | Page `paging.page` of that size | | `QUERY` with nonpositive `pageSize`, or without `pageSize` | Unpaged; a page without a size is ignored | | `QUERY` with nonnumeric or out-of-int32 paging values | 400 exception envelope (redacted unless `exposeExceptionDetails`); even a page without a size must be an integer; the handler does not run | | `sortBy` or `sorting.field` | Sort by that field. The name must start with a letter and contain only letters, digits, and `_`. | | `sortDirection` or `sorting.direction` | `asc`, `ascending`, `desc`, or `descending`, in any case; `asc` by default. A QUERY direction without a field is ignored; GET rejects it. | Invalid directions answer 400 with `malformedRequest` and the `sortDirection` (GET) or `sorting.direction` (`QUERY`) member. Page offsets are clamped to the signed 32-bit maximum before slicing in memory, so large valid page and size values cannot overflow the offset. Providers that cut their own pages must apply equivalent bounds before using the offset in their data source. In-memory sorting requires the field on every item, or the request answers 400. Dates compare by time, numbers and bigints numerically, `false` before `true`, and `null` or `undefined` before any value in ascending order. Other scalar values compare as strings with `localeCompare`, which is not .NET invariant-culture collation; sort in the data source when a stable cross-platform order matters. In-memory array sorting rejects present, non-null complex values with the existing `malformedRequest` validation result (HTTP 400 for snapshots; a validation result if streaming has already started), without changing provider-owned sorting; raw BSON scalars remain accepted with string comparison. A query that returns something other than an array answers 400 when the request asks for paging or sorting. ## Return a page your data source cut Loading every row to page it in memory does not scale. Add `queryOptions()` to receive the request’s paging and sorting, cut the page in your data source, and return `queryPage(items, totalItems)`, as `recent` does. `GET /api/recent?pageSize=2&page=1` returns `Fig` with `totalItems: 3`. * `items` must be exactly the requested page: the page size, or fewer on the last page. For an unpaged request, `items` must hold all `totalItems`. Anything else answers 400. * A request that asks for sorting answers 400 unless you pass the applied sort as the third argument, `queryPage(items, totalItems, sorting)`, confirming your data source sorted it. Arc never re-sorts a page it did not cut. * `queryPage` throws when `totalItems` is negative, not a safe integer, or smaller than the number of items; the query then fails with a 500. A provider can throw the public `QueryPagingRequired(maxPageSize, unpaged?, message?)` error to report its own limit. Arc maps its message to a 400 `rule` validation result on `Size`. Pass `true` for `unpaged` when a result without paging exceeds the limit; supply a message only when the default request-paging advice does not apply to the operation. Similarly, `InvalidQuerySort` maps to a 400 `rule` result on `sorting.field` for a provider-rejected sort field. The [MongoDB](/arc/backend/typescript/mongodb/paging/) and [Drizzle](/arc/backend/typescript/sql/paging/) integrations return such pages for you, with sorting pushed into the database. Without paging, their `queryPage` reads at most the configured maximum page size. If more rows exist, the provider answers 400 with a `rule` result on `Size` asking the caller to request paging, rather than returning a misleading partial list. A requested page size above the provider maximum also answers 400 with a `rule` result on `Size`. ## Related * [Query arguments](/arc/backend/typescript/queries/model-bound/query-arguments/) * [Query renderers](/arc/backend/typescript/queries/renderers/) for provider-owned results * [Calling commands from code](/arc/backend/typescript/commands/calling-commands-from-code/), where `performQuery` takes the same options # Query arguments > Bind named, optional, typed, and repeated query arguments from the query string, and know which values Arc converts and which it rejects. A search query takes tags, a limit, and a flag. You declare each argument once, with its wire type, and Arc binds it by name from the query string, or from a structured `QUERY` body. ## Declare arguments Item.ts ```typescript import { field } from '@cratis/fundamentals'; import { argument, query, readModel } from '@cratis/arc.core'; @readModel() export class Item { @field(String) name!: string; @query(argument('tags', Array, { elementType: String }), argument('limit', Number, { optional: true }), argument('open', Boolean, { optional: true })) static search(tags: string[], limit: number | undefined, open: boolean | undefined): Item[] { return [{ name: `${tags.join('+')}:${limit}:${open}` }]; } } ``` `GET /api/search?tags=a&tags=b&limit=5&open=true` binds `tags: ['a', 'b']`, `limit: 5` as a number, and `open: true`. `GET /api/search?tags=a` leaves the optional arguments `undefined`. | `argument(...)` form | Meaning | | --- | --- | | `argument('id', TaskId)` | A required argument decoded to the given wire type, here a concept | | `argument('limit', Number, { optional: true })` | An optional argument; type the parameter `number \| undefined`, not `limit?: number` | | `argument('ids', Array, { elementType: TaskId })` | A repeated GET key, each value decoded to the element type | The wire types are the same as for fields; see [Concepts](/arc/backend/typescript/concepts/). ## GET binding rules * Argument names match case-insensitively: `?Tags=a&LIMIT=2` binds `tags` and `limit`. * An argument the query does not declare answers 400 `malformedRequest`. * Numbers and booleans are converted from text. An invalid value, such as a malformed UUID for a `Guid` or concept argument, answers 400 `malformedRequest`. * A key may repeat only for an array argument; a single value becomes a one-element array. Repeating any other key, or repeating a key with different casing, answers 400. * `page`, `pageSize`, `sortBy`, and `sortDirection` are reserved for [paging and sorting](/arc/backend/typescript/queries/model-bound/paging/) and never reach your arguments. For structured arguments, such as nested objects, use the [HTTP `QUERY` method](/arc/backend/typescript/queries/using-the-http-query-method/). ## Low-level queries A `defineQuery` definition binds the same way against its Zod schema: a `z.number()` or `z.boolean()` property is converted from text, also through `.optional()`, `.default(...)`, and `.nullable()`, and a key may repeat only for a `z.array(...)` property. Other values stay strings. ## Related * [Model-bound queries](/arc/backend/typescript/queries/model-bound/) * [Query validation](/arc/backend/typescript/queries/validation/) # Observable queries > Serve a current snapshot and live updates from one query route, choose a source, and control paging, authorization, and subscription lifetime. A task board should update when someone adds a task, without the browser polling. An observable query serves the current snapshot on an ordinary GET and streams every change to subscribers, from the same route and the same pipeline. ## Declare an observable query The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Listing/Listing.ts) declares its live list as an ordinary query that returns a source: ```typescript @query() static observeAllTasks(tasks: Tasks): BehaviorSubject { return tasks.observeAll(); } ``` The sample’s `Tasks` service keeps a `new BehaviorSubject([])` from RxJS and calls `next(...)` whenever a task is registered. Arc has to know that a query is observable before it runs: snapshots, server-sent events, WebSocket admission, introspection, and generated clients all depend on it. The sample installs [generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/), which reads the declared return type, so bare `@query()` is enough. Without generated metadata, say so explicitly and list the parameters: ```typescript @query({ observable: true }, service(Tasks)) static observeAllTasks(tasks: Tasks): BehaviorSubject { return tasks.observeAll(); } ``` ## Choose a source | Source | Snapshot behavior | | --- | --- | | RxJS `BehaviorSubject` | Has a current value: GET answers 200 immediately, and new subscribers receive it first | | RxJS `Subject` or `Observable` | No current value: GET answers 202 with `isReady: false`; `waitForFirstResult=true` subscribes until the first value | | RxJS `ReplaySubject` | No readable current value: GET answers 202 even after an emission; a waiting GET receives the buffered value | | `AsyncIterable` | No current value until the first item | | `CurrentValueSubject` (deprecated) | Legacy current/pending source; use RxJS `BehaviorSubject` or `Subject` instead | A `BehaviorSubject` exposes its current value, including `undefined`; `Subject` and `ReplaySubject` do not. The core accepts structural subscribables and async iterables without loading RxJS at runtime, so RxJS is an optional peer dependency for consumers using only async iterables. ## When there is nothing to show A live lookup, such as “the task with this ID”, may have nothing to show yet. Two different situations look alike from the outside: | The source | A snapshot GET answers | A subscriber receives | | --- | --- | --- | | Has no current value yet (`Subject`, `Observable`, an empty async iterable) | 202 with `isReady: false` | Nothing until the first emission | | Emits `undefined` or `null` (a `BehaviorSubject` with nothing stored) | 200, `isReady: true`, no `data` property | A ready result without `data` | In both cases the subscription stays open. When the document appears and the source emits it, the subscriber receives it like any other update, and when it disappears again the source can emit `undefined`. Emit `undefined` for “we looked and nothing is there”, and leave a source silent only when you genuinely do not know yet; a client can tell the two apart by `isReady`. An error is neither. A source that errors ends the subscription with a failed result, and a source that completes before its first value returns an error to a waiting GET. Do not turn a storage failure into an `undefined` emission. ## Subscription lifetime Each subscription runs your query method once, after authorization and validation, and then follows the source it returned: * The subscription owns its own service scope for its whole life. Scoped services are not shared between subscribers, and they are disposed when the subscription ends. * The method can read the caller with `currentContext()` from `@cratis/arc.core`, which returns the subscription’s execution context, and its `signal` aborts when the subscription ends. Pass it to anything that must stop with the subscriber. * The subscription ends when the client disconnects or unsubscribes, when the source completes or errors, or when an [emission guard](/arc/backend/typescript/queries/observable-query-emission-guards/) denies an emission. Arc then cancels the source and disposes the scope. ## Read the snapshot and subscribe from the terminal With the Tasks sample running: ```bash curl http://127.0.0.1:3000/api/tasks/listing/observe-all-tasks curl -N -H 'Accept: text/event-stream' http://127.0.0.1:3000/api/tasks/listing/observe-all-tasks ``` The first answers 200 with the current tasks in `data`. The second keeps the connection open and prints a `data: ` frame now, and another whenever you register a task. [Using observable queries with curl](/arc/backend/typescript/queries/using-observable-queries-with-curl/) covers waiting for a first result and the error codes. ## Page and sort a live list The paging and sorting parameters work on an observable query exactly as on a snapshot query, and they apply to **every** emission: ```bash curl -N -H 'Accept: text/event-stream' \ 'http://127.0.0.1:3000/api/tasks/listing/observe-all-tasks?pageSize=1&page=1&sortBy=title&sortDirection=desc' ``` With two tasks registered, each frame holds the second task in descending title order, and `paging` reports `{"page":1,"size":1,"totalItems":2,"totalPages":2}`. When a third task arrives, the next frame is sorted and cut again, and `totalItems` follows the whole list. A generated client’s `useWithPaging(pageSize)` hook sends the same parameters. Arc pages the arrays your source emits, in memory. Model-bound observable queries cannot return a `queryPage`; generated metadata rejects that declaration at build, and generated clients do not support observable-page results. Drizzle model-bound queries use `observe()` for complete small arrays, as MongoDB does. For larger collections, narrow the source with query arguments. Low-level `defineObservableQuery` without generated metadata or proxies can emit a `QueryPage` (including Drizzle’s `observePage`); Core validates and streams each page, but the generated client cannot consume this shape. The [paging rules](/arc/backend/typescript/queries/model-bound/paging/#request-parameters) for invalid sizes and sort fields are the same as for snapshots. ## Authorize a live query An observable query takes the same `@roles`, `@authorize`, and `@allowAnonymous` declarations as any query, and Arc checks them, with validation, when a subscription opens. A denied snapshot GET answers 401 or 403 with `isAuthorized: false`, following the [status code rules](/arc/backend/typescript/authorizing-commands-and-queries/#status-codes); a denied hub subscription receives an `Unauthorized` frame. The query method never runs for a denied caller. That check happens **once**. Arc keeps a copy of the caller’s identity for the life of the subscription and does not re-run authorization on each emission, so a role revoked or a session that expires after the subscription opened does not close it. When access must be re-checked while the stream runs, add an [emission guard](/arc/backend/typescript/queries/observable-query-emission-guards/), which sees every result before delivery and can end the subscription. A role decides who may subscribe, not which rows they see. For a query like “my tasks”, filter inside the source by the caller’s identity: MyTasks.ts ```typescript import { field } from '@cratis/fundamentals'; import { authorize, currentContext, query, readModel } from '@cratis/arc.core'; import { BehaviorSubject, map, type Observable } from 'rxjs'; const tasks = new BehaviorSubject([]); @readModel() @authorize() export class OwnedTask { @field(String) id!: string; @field(String) title!: string; @field(String) owner!: string; @query({ observable: true }) static myTasks(): Observable { const caller = currentContext()?.principal?.id; return tasks.pipe(map(all => all.filter(task => task.owner === caller))); } } ``` `@authorize()` turns anonymous callers away before `myTasks` runs, so `caller` is always an authenticated ID. Each subscriber gets their own filtered stream: when a task owned by `ada` is added, only Ada’s subscription emits a new list with it. The filter runs in the producer, so rows the caller must not see never enter the result at all. Never leave that filtering to the client. ## Subscribe from a client A browser can subscribe over direct server-sent events or a direct WebSocket, and the `@cratis/arc` client subscribes through generated proxies over a multiplexed hub. [Subscribe to an observable query](/arc/backend/typescript/queries/subscribing-to-observable-queries/) shows each one. ## Without decorators `defineObservableQuery` takes an `observe` callback instead of a decorated method. This complete low-level example needs no build step on Node.js 22.19 or later, which strips types by default; run it with `node observable.ts` inside the workspace: observable.ts ```typescript import express from 'express'; import { z } from 'zod'; import { ArcServer, defineObservableQuery } from '@cratis/arc.core'; import { BehaviorSubject } from 'rxjs'; import { cratisArc } from '@cratis/arc.express'; const numbers = new BehaviorSubject([1]); const query = defineObservableQuery({ name: 'Numbers', schema: z.object({}), observe: () => numbers }); const server = new ArcServer({ observableQueries: [query] }); const app = express(); const adapter = cratisArc(server); app.use(adapter); const listener = app.listen(3000, '127.0.0.1'); adapter.injectWebSocket(listener); let nextNumber = 2; const timer = setInterval(() => numbers.next([nextNumber++]), 1000); process.once('SIGINT', () => { clearInterval(timer); void (async () => { try { await adapter.close(listener); // Drain WebSockets and SSE, then close the listener. } finally { await server.dispose(); } })(); }); ``` `curl http://127.0.0.1:3000/api/numbers` answers 200 with `data: [1]` or a later number. `observe` may resolve services through `currentServices()`. ## Recap One route serves a snapshot and a stream. Return a source that has a current value when you have one, emit `undefined` for “nothing there”, and let errors be errors. Paging and sorting apply to every emission; authorization applies once, when the subscription opens, and emission guards cover what changes after that. Filter rows by the caller inside the source. ## Next step [Subscribe to an observable query](/arc/backend/typescript/queries/subscribing-to-observable-queries/) connects a browser or the `@cratis/arc` client to the route you just declared. ## Related * [Subscribe to an observable query](/arc/backend/typescript/queries/subscribing-to-observable-queries/) * [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/) * [Observable emission guards](/arc/backend/typescript/queries/observable-query-emission-guards/) * [Testing observable queries](/arc/backend/typescript/testing/observable-queries/) * [MongoDB change streams](/arc/backend/typescript/mongodb/observing-collections/) # Multiplexed observable queries > Carry many observable-query subscriptions over one WebSocket or server-sent-events hub connection, choose a transfer mode, and size the hub's limits. A dashboard with ten live widgets should not open ten sockets. The multiplexed hub carries every subscription from one client over a single connection, and it is the default transport of the published `@cratis/arc` client. The plain `@cratis/arc` client uses the WebSocket hub by default. The `` provider from `@cratis/arc.react` uses the SSE hub by default. Both work with anonymous callers; every subscription still runs through query authorization. ## The WebSocket hub The hub lives at `/.cratis/queries/ws`. After `Connected`, the client subscribes by query name: ```javascript const hub = new WebSocket(`ws://${location.host}/.cratis/queries/ws`); hub.onmessage = event => { const frame = JSON.parse(event.data); if (frame.type === 'Connected') { hub.send(JSON.stringify({ type: 'Subscribe', queryId: 'tasks', revision: 1, payload: { queryName: 'Tasks.Listing.TaskItem.observeAllTasks', transferMode: 'full' } })); } if (frame.type === 'QueryResult') console.log(frame.payload.data); }; // To stop only this subscription: // hub.send(JSON.stringify({ type: 'Unsubscribe', queryId: 'tasks', revision: 1 })); ``` `queryName` is the query’s full identity; see [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/#the-convention). Each subscription is authorized independently through the query pipeline; an unauthorized query receives an `Unauthorized` frame. ## The server-sent-events hub The SSE hub uses `GET /.cratis/queries/sse` for its stream, plus `POST /.cratis/queries/sse/subscribe` and `/unsubscribe` controls. The stream opens with a `Connected` frame whose payload is the connection ID; every control request names it. The stream can be opened anonymously. Browser `EventSource` sends same-origin cookies but cannot set an `Authorization` header. If your queries require sign-in, use your application’s real session cookie on the stream and controls; the `.cratis-identity` display cookie is not a credential. SSE controls require `application/json` (optionally `charset=utf-8`) and reject an untrusted browser `Origin` with 403. Same-origin is allowed by default; configure `query.allowedOrigins` for trusted cross-origin frontends. ### Response headers The hub stream, and a direct SSE stream for one query (`GET` on the query route with `Accept: text/event-stream`), answer 200 with these headers: | Header | Value | | --- | --- | | `Content-Type` | `text/event-stream; charset=utf-8` | | `Cache-Control` | `no-cache` | | `Connection` | `keep-alive` | | `X-Accel-Buffering` | `no`, which asks a buffering reverse proxy to pass each frame through as it is written | | `X-Correlation-ID` | The request’s correlation ID, under the header name set by `correlationId.httpHeader` | `no-cache` lets a cache store the response only if it revalidates before reuse. Arc sends `Cache-Control: no-store` on HTTP `QUERY` responses and `/.cratis/me`, not on SSE streams. If a proxy or CDN in front of Arc must never store a stream, configure that in the proxy. ### Who can control a connection The connection ID is crypto-random. A control request must also come from the caller that opened the connection: * It must resolve to the same tenant and the same authentication state. Anonymous callers cannot control authenticated connections, and authenticated callers cannot control anonymous ones. * For an authenticated connection, it must carry the same principal ID. * For an anonymous connection, it must come from the same peer address as the stream. Arc for TypeScript adds this check; Arc on .NET does not have it. When neither request has a peer address, the check cannot tell anonymous callers in the same tenant apart, so anyone who learns the connection ID can control the connection. An unknown connection, or one the caller does not own, answers 404. A query the caller may not access answers 401 with an `Unauthorized` frame. The peer-address check is not authentication: behind a shared proxy, every client has the proxy’s address. ### Per-caller budgets Every hub connection and every live subscription counts against a per-caller budget. Arc decides who the caller is from the request: | Caller | Shares a budget with | | --- | --- | | Authenticated | Requests with the same principal ID in the same tenant | | Anonymous, with a peer address | Anonymous requests from the same address in the same tenant | | Anonymous, without a peer address | Every anonymous request without an address in the same tenant | Two options set the budgets: | Option | Default | Counts | | --- | --- | --- | | `query.maxObservableHubConnectionsPerCaller` | `512`, the same as `query.maxObservableHubConnections` | Open WebSocket and SSE hub connections | | `query.maxObservableSubscriptionsPerCaller` | `4096`, the same as `query.maxObservableSubscriptions` | Live subscriptions, direct or on a hub, including ones still opening | With the defaults, one caller can take the whole global capacity. Lower both for an internet-facing host. Opening an SSE stream or SSE hub over budget answers 503 with `Retry-After: 1`, and a WebSocket hub over budget is refused with 503 during the upgrade, or closed with code 1013 if the budget ran out while it connected. A hub subscription over budget receives an `Error` frame, and its SSE control request answers 503. ### Peer addresses and proxies Where the peer address comes from depends on the host: * **Express, Fastify, and Hono on the Node server** and **the standalone host** (`app.run()`, `runArc`, and `createArcNodeHandler`) read it from the TCP socket, unless the native callback returns a `remoteAddress`. * **Fetch API hosts** (`app.fetch`, `app.handle`, and `server.handle`) have no socket. Pass `remoteAddress` in the native context. Arc never reads `X-Forwarded-For` or `Forwarded`. Behind a reverse proxy, the socket address is the proxy’s, so all anonymous clients behind it share one budget, and the peer-address check only shows that a control request came through the same proxy. A low per-caller cap then throttles those clients together. To budget them one by one, validate the forwarding header against your trusted proxy chain in the native callback and return the client address as `remoteAddress`. [Native principal](/arc/backend/typescript/hosts/native-principal/) describes the callback, and [WebSockets](/arc/backend/typescript/hosts/websockets/) shows a trusted-proxy check for upgrades. ## Configure the installed client ```typescript import { Globals } from '@cratis/arc'; import { QueryTransportMethod } from '@cratis/arc/queries'; Globals.queryDirectMode = false; Globals.queryTransportMethod = QueryTransportMethod.WebSocket; // or QueryTransportMethod.ServerSentEvents Globals.observableQueryTransferMode = 'full'; ``` ## Transfer modes | Mode | What each result carries | | --- | --- | | `full` | The complete snapshot every time | | `delta` (client default) | The first enumerable result carries full `data`; later results carry `{ added, replaced, removed }` of full items **without** `data` | | Absent or unrecognized | Full data plus a change set (legacy) | Scalar results always carry full data. The installed client’s `ObservableQueryFor.subscribe` callback does not rebuild arrays from deltas: its callback receives `data: []` next to the raw change set. Choose `full` when the callback needs the whole array on every update. Items are matched by an `id` property; the property name is matched case-insensitively, while identity values compare by case and JSON type (`"A"` differs from `"a"`, and `1` differs from `"1"`). Items with missing or duplicate IDs fall back to JSON set comparison. A change set cannot represent order-only or duplicate-count-only changes. ## Revisions and keep-alive `Connected` advertises revisions and a keep-alive interval, 30 seconds by default. Each outbound frame reschedules the next idle ping; `query: { keepAliveIntervalMs: 0 }` disables keep-alive. Positive safe-integer revisions supersede legacy subscribes and reject stale or duplicate operations. Unsubscribe tombstones live for two minutes, at most 1024 per connection by default. ## Limits By default the server allows 4096 subscriptions globally and per caller, 512 hub connections globally and per caller, 256 subscriptions per hub connection, and 256 queued inbound and outbound frames. Per-caller limits default to the global ones, so one caller can exhaust capacity: set lower `query.maxObservableSubscriptionsPerCaller` and `query.maxObservableHubConnectionsPerCaller` for internet-facing hosts. [Per-caller budgets](#per-caller-budgets) explains how Arc tells callers apart. `query.observableShutdownTimeoutMs` (10 seconds) bounds hub and direct WebSocket cleanup at shutdown. All limits are listed in [Configuration](/arc/backend/typescript/configuration/#observable-query-limits). ## Related * [Observable queries](/arc/backend/typescript/queries/observable-queries/) * [Query health](/arc/backend/typescript/queries/query-health/) * [WebSockets](/arc/backend/typescript/hosts/websockets/) # Observable emission guards > Re-check every observable-query emission with scoped guards that allow, suppress, or deny and terminate the subscription. Authorization runs once, when a subscription opens. A live query can outlive that decision: the caller’s access may lapse, or some emissions should never leave the server. An emission guard checks every rendered result, including the first snapshot, before it is delivered. ## Write and register a guard ```typescript import { field } from '@cratis/fundamentals'; import { ArcApplication, ObservableEmissionDecision, query, readModel, serviceToken, type ObservableEmissionContext, type ObservableEmissionGuard } from '@cratis/arc.core'; import { BehaviorSubject } from 'rxjs'; const prices = new BehaviorSubject([]); @readModel() export class Price { @field(String) symbol!: string; @field(Number) amount!: number; @query({ observable: true }) static livePrices(): BehaviorSubject { return prices; } } export const tradingHours = serviceToken('tradingHours'); export class TradingHoursGuard implements ObservableEmissionGuard { check(emission: ObservableEmissionContext): ObservableEmissionDecision { if (!emission.context.principal) return ObservableEmissionDecision.DenyAndTerminate; return Array.isArray(emission.data) && emission.data.length === 0 ? ObservableEmissionDecision.Suppress : ObservableEmissionDecision.Allow; } } const builder = ArcApplication.createBuilder({ query: { observableEmissionGuards: [tradingHours] } }); builder.services.addScoped(tradingHours, () => new TradingHoursGuard()); builder.add(Price); ``` Without an authenticated caller, `GET /api/live-prices` answers 403 with `isAuthorized: false`. With one, an empty list is suppressed, so the snapshot answers 202 with `isReady: false` until a non-empty list arrives. ## Decisions | Decision | Effect | | --- | --- | | `Allow` | Deliver the result | | `Suppress` | Withhold it without advancing the delta baseline | | `DenyAndTerminate` | Send a terminal unauthorized result or hub frame and end the subscription | The most restrictive decision across all guards wins. A guard that throws denies and ends the subscription, including while evaluating an HTTP snapshot, and the failure is logged. ## What a guard sees `ObservableEmissionContext` carries `queryName`, the bound `input`, the rendered `data`, the execution `context` (principal, tenant, correlation), `isFirstEmission`, and a `signal`. Guards run in the subscription’s service scope, after [renderers and interceptors](/arc/backend/typescript/queries/query-pipeline/#result-stages), on an isolated copy of the context. Register guard tokens with the `query.observableEmissionGuards` option, and register each token as a service. ## Related * [Observable queries](/arc/backend/typescript/queries/observable-queries/) * [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/) # Query filters > Admit every query before validation with scoped authorization filters, or reject a query with ordinary pipeline filters. Global query filters run for snapshot GET and `QUERY` requests and at admission for observable subscriptions (direct SSE, WebSocket, and multiplexed hubs), including the opt-in authenticated `QueryHealth.ObserveHealth` operation. They do **not** run for every emission. Use an [emission guard](/arc/backend/typescript/queries/observable-query-emission-guards/) if access needs to be checked throughout a live subscription. `AuthorizationQueryFilter` runs first, before validator or performer dependencies are constructed. `QueryPipelineFilter` runs next, before validation and the performer. Both expose `onPerform(context: QueryContext)`, which may return a `QueryResult` fragment or nothing. The context carries the bound `query` arguments, request identity, query `options`, and an `operationName` equal to the namespace-qualified declaration identity shown in introspection. Framework-created contexts make this name non-writable and non-configurable; manually built contexts may omit it. A filter gating on the name must deny absent identity or explicitly handle a manual context, never infer identity from query payload. When schema parsing fails, authorization filters see raw input and ordinary filters do not run. Transport argument coercion errors are rejected before the filter pipeline. A denial answers 403 with no data or validation results. Query denials have no reason field. query-gates.ts ```typescript import { ArcApplication, authorizationQueryFilter, queryPipelineFilter, queryFilterResult, unauthorizedQueryResult, validation, type AuthorizationQueryFilter, type QueryPipelineFilter, type QueryContext, type QueryResult } from '@cratis/arc.core'; @authorizationQueryFilter() class TenantGate implements AuthorizationQueryFilter { onPerform(context: QueryContext): QueryResult | void { if (!context.tenantId) return unauthorizedQueryResult(context); } } @queryPipelineFilter() class QueryGate implements QueryPipelineFilter { onPerform(context: QueryContext): QueryResult | void { if (typeof context.query === 'object' && context.query !== null && 'value' in context.query && context.query.value === '') { return queryFilterResult(context, { validationResults: [validation('Value is required', ['value'])] }); } } } export const builder = ArcApplication.createBuilder(); builder.add(TenantGate, QueryGate); ``` You can also register tokens using `ArcOptions.authorizationQueryFilters` and `.queryPipelineFilters`, or `builder.addAuthorizationQueryFilter(token)` and `.addQueryPipelineFilter(token)`. Register explicit tokens as scoped or transient services. Within each group the order is ArcOptions tokens, then explicit builder calls, then decorated classes in `add()` order. A token registered more than once runs once; a token in both groups fails at build. An unsuccessful fragment stops that group and skips subsequent stages. As with global command filters, Arc applies `context.allowedSeverity` to each normalized fragment before deciding whether to stop: a filtered-out warning cannot skip a later authorization filter. Denial always stops the chain and exposes no validation details. This TypeScript severity-before-short-circuit behavior intentionally differs from .NET global filters. Invalid fragments and thrown filters fail closed with 500. Omitted fragment fields retain their defaults; supplied authorization and readiness must be boolean, and validation severity must be a `Severity` value. The existing per-definition `QueryFilter` callbacks remain separate and collect validation results at the validator stage. A pre-aborted direct query resolves or invokes no declared authorization policy. Arc checks cancellation before each named policy resolution and invocation and after each awaited resolution, as well as before each filter, validator dependency or callback, performer dependency, `perform()`, and renderer/interceptor resolution and invocation. If cancellation occurs while one of these stages is pending, Arc waits for it to settle, fails the request without starting the next policy or stage or the snapshot/observable query producer, and releases its operation scope. Already-running callbacks are not interrupted. The admission-filter specs exercise in-process observable hub admission and direct SSE for health. The conformance suite pairs GET, `QUERY`, observable snapshots, and direct SSE filter denial and allow against .NET on Express, Fastify, and Hono. It also checks direct WebSocket denial and allow, and that the host keeps serving direct SSE/WebSocket subscriptions after an early disconnect, on each TypeScript adapter; the .NET HTTP fixture has no direct WebSocket upgrade. TypeScript fixture traces check authorization before ordinary filters before validation and verify that denial does not construct validator/performer dependencies or start the observable producer. See [Query pipeline](/arc/backend/typescript/queries/query-pipeline/) for the complete order and [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/) for declared authorization. # Query health > Opt in to a caller-scoped observable query that reports the caller's own hub connections, subscriptions, and query groups. When a live widget stops updating, the first question is whether its subscription still exists. The query health endpoint answers that for the caller asking, and nobody else. ## Turn it on ```typescript const builder = ArcApplication.createBuilder({ query: { enableObservableHealth: true }, authentication: [/* verified handlers */] }); ``` `query.enableObservableHealth: true` registers `/.cratis/queries/health` as an observable query for GET and `QUERY`. It requires authentication. ## What it reports An authenticated caller sees only its own tenant and principal’s **hub** connections, their subscription counts, and query groups. Direct SSE and WebSocket connections and remote IP addresses are not included. Other callers’ unchanged snapshots are not emitted to you, and your own bursts are coalesced. The built-in health query is not emitted as an application proxy in the client manifest. A deliberate difference from Arc on .NET Arc on .NET exposes a broader, anonymous, cross-caller health view. Arc for TypeScript keeps it disabled by default and scoped to the caller, because connection metadata is sensitive. ## Related * [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/) * [Configuration](/arc/backend/typescript/configuration/#observable-query-limits) # Query pipeline > The order in which Arc authenticates, authorizes, binds, and validates a query, then renders, intercepts, sorts, and pages its result. A query goes through the same guard stages as a command, then a second set of stages that shape the result. Knowing the order tells you where a renderer, an interceptor, or a guard sees the data, and why a request for sorting can fail after your method already ran. ## Guard stages | Step | You configure it with | When it fails | | --- | --- | --- | | 1. Authentication | `authentication` handlers, or a native principal | 401 | | 2. Tenant | `tenancy.httpHeader`, `tenancy.sources`, or `tenancy.resolve` | 400 or 403 only when `tenancy` requires it | | 3. Read arguments | The query string for GET, the body for `QUERY` | 400 `malformedRequest` for rejected input; an unreadable `QUERY` body produces a 400 exception envelope, while invalid paging produces a 400 `rule` result | | 4. Declared authorization | `@roles`, `@authorize`, `@allowAnonymous`, or `authorization` | 403 | | 5. Bind arguments | `argument(...)` descriptors, or a Zod `schema` | A wrong shape is held until global authorization finishes, then 400 `malformedRequest` | | 6. Per-request authorization | `authorize(input, context)` on a successfully bound low-level definition | 403 | | 7. Global authorization filters | Scoped `AuthorizationQueryFilter` services | 403 for denial; a thrown filter fails closed with 500 | | 8. Global ordinary filters | Scoped `QueryPipelineFilter` services | 400 for validation, 500 for exceptions | | 9. Validation | `QueryValidator`, concept validators, `validate`, `filters` | 400 with every result | | 10. Perform | Your query method, or `perform` / `observe` | 500 when it throws | The allowed severity for queries is always `Warning`. [Query filters](/arc/backend/typescript/queries/query-filters/) run once at admission, before validator and performer dependencies are constructed. A malformed schema shape reaches authorization filters with raw arguments but does not reach ordinary filters; transport argument coercion errors return before filters. These stages match the [command pipeline](/arc/backend/typescript/commands/command-pipeline/). ## Result stages After the method returns, Arc shapes the value in the same request or subscription scope: 1. **Renderers.** The first registered [renderer](/arc/backend/typescript/queries/renderers/) whose `canRender` accepts the value turns it into data or a `queryPage`. 2. **Read-model interceptors.** Each registered [interceptor](/arc/backend/typescript/queries/read-model-interception/) for the exact runtime class of an item transforms it, including items inside a provider-owned page. An interceptor that implements `isReleased` also protects nested instances of its model: the query fails if one is found that it does not report released. 3. **Sorting and paging.** An array is sorted, then paged, in memory. A `queryPage` is used as is. See [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/). 4. **Encoding.** Decorated models and concepts become their wire shape. For an observable query, the guard stages (including global query filters) run **once** when the subscription opens. Result stages 1 to 4 run for the current snapshot and for **every** emission, and [emission guards](/arc/backend/typescript/queries/observable-query-emission-guards/) run after rendering, before each delivery. A pre-aborted query starts no declared authorization or named policy. Arc checks cancellation before each declared authorization policy resolution and invocation and after each awaited resolution; the same admission rule applies to filters, validators, dependency resolution, `perform()`, renderer and interceptor service resolutions and invocations, and `render()`. If cancellation occurs during a policy or result-stage callback, Arc waits for that callback to settle, then fails without starting the next policy or result stage or delivering successful data. In-flight effects are not undone; request and subscription scopes still close. ## Consequences * A request for paging or sorting on a non-array, non-page result answers 400 after the method has run. * An interceptor never sees a value a renderer turned into something other than its exact class. * Interceptors and emission guards do not replace admission authorization; decide initial access in the guard stages. ## Related * [Queries](/arc/backend/typescript/queries/) * [Observable queries](/arc/backend/typescript/queries/observable-queries/) # Choose Chronicle read consistency > Select materialized or on-demand read models and optionally wait for observer completion after a command. A successful Chronicle append commits the events; it does not, by default, mean a query of an active projection sees them yet. `ChronicleReadModels` and the Chronicle command options let you make the required consistency explicit without changing non-Chronicle Arc queries. This integration remains experimental. Import `ChronicleReadConsistency` from `@cratis/arc.chronicle` when requesting an immediate read. | Choice | How to select | What a successful return establishes | | --- | --- | --- | | Default | `models.getById(View, id)` or `models.getAll(View)` | Chronicle SDK read. Active projections use their asynchronously materialized state; passive projections resolve on demand. | | Immediate read | Mark the model-bound projection `@passive`, then call `models.getById(View, id, ChronicleReadConsistency.Immediate)`, `models.findInstanceById(View, id, ChronicleReadConsistency.Immediate)`, or `models.getAll(View, ChronicleReadConsistency.Immediate)` | The SDK asks the kernel to compute the passive read model from events at query time. An active model passed as `immediate` is rejected, never silently served stale data. This does not promise a snapshot across concurrent queries. | | Wait after write | Set `completionTimeoutMs: 10000` on `builder.withChronicle({ eventStore, client, completionTimeoutMs: 10000 })`; or on a `defineChronicleCommand` definition | Arc waits on the last successful append’s `waitForCompletion` before returning success. The kernel reports when the observers on the event log have caught up to that tail or failed. No fixed sleep or read-model polling. | Without `completionTimeoutMs`, commands acknowledge persistence only. The timeout is a positive integer in milliseconds; the SDK bounds its completion RPC by that value (default opt-in value is **none**). Arc also cancels the caller’s wait if the command context signal aborts. If observers fail or the wait times out, the command reports an exception, **not success**; the events may already be committed. Do not retry a non-idempotent command merely because the wait failed. The wait does not make reactors’ external side effects atomic, and does not guarantee that a later query sees no newer concurrent writes. Chronicle currently waits for **every** observer on the event log, including projections and reactors that do not handle the appended event type. Such an observer never catches up to the tail, so the wait runs for the full timeout and the command reports an exception although its events were appended. Enable the wait only where every observer handles every event type your commands append. This is [Cratis/Chronicle#4132](https://github.com/Cratis/Chronicle/issues/4132). The SDK’s `getInstances`/`findInstanceById` choose sink versus on-demand based on how the read model was registered. Arc’s explicit `immediate` mode is limited to passive **model-bound** projections, as identified by Chronicle’s `@passive`; it does not convert an active projection or reducer into an on-demand read. For hot list views use active projections and tolerate a lag, or enable the bounded completion wait when a particular write must be visible before a follow-up query. See [Chronicle’s read-model consistency guide](https://github.com/Cratis/Chronicle/blob/main/Documentation/read-models/consistency.md) for the kernel-side distinction. # Intercept read models > Transform a read-model instance in each scoped snapshot and observable emission before it reaches the wire. Several queries return the same read model, and each one has to trim or reshape it the same way before it leaves the server. Copying that logic into every query method means the next query forgets it. A read-model interceptor transforms the model once, for every query that returns it. Arc runs the interceptor on ordinary query results, on observable HTTP snapshots, and on **each** stream emission, before encoding the model. That includes the items of a provider-owned `queryPage`; totals and paging stay with the provider. ## Write an interceptor ```ts import { ArcApplication, readModelInterceptor, type ReadModelInterceptor } from '@cratis/arc.core'; import { field } from '@cratis/fundamentals'; class AccountSummary { @field(String) name!: string; } @readModelInterceptor() class PublicAccountName implements ReadModelInterceptor { readonly model = AccountSummary; intercept(account: AccountSummary): AccountSummary { const copy = new AccountSummary(); copy.name = account.name.trim(); return copy; } } const builder = ArcApplication.createBuilder(); builder.add(PublicAccountName); // Or register a service and call addReadModelInterceptor(token). // Add your decorated read model and other services, then call builder.build(). ``` Return a replacement. Do not mutate a model that another subscriber may share. ## How interceptors run * `builder.discover()` also loads `@readModelInterceptor()` classes. They default to a scoped service lifetime. * Interceptors run in registration order and match the **exact runtime class** of each item. They do not apply to unrelated scalar values or to plain objects of a different class. * The same scoped instance serves every emission in one subscription and is disposed when that subscription closes. * A failed interception fails the query or the emission. It cannot silently return the original data. * Unlike the current .NET `ObservableQueryHttp` path, TypeScript also intercepts observable HTTP snapshots, so a GET cannot bypass the interceptor. * Arc intercepts the top-level result, each array item, and each item of a `queryPage`. An instance nested deeper, for example `{ account, related }` or a joined `select` result, is not transformed. By default it is served as it is, exactly as before; an interceptor that must protect its model everywhere opts in with `isReleased` (below). The [query pipeline](/arc/backend/typescript/queries/query-pipeline/#result-stages) shows where interceptors run relative to renderers, paging, and emission guards. ## Protect a model wherever it appears A plain interceptor, like the one above, only reshapes the slots Arc intercepts. When a model holds data that must never leave the server untransformed, such as encrypted personal data, implement the optional members that make the interceptor protect it: * `isReleased(model)` opts the model in to protection. Arc then walks every result and emission, and fails the query if it finds an instance of the model for which `isReleased` does not return `true`, wherever it appears. When several interceptors for the same model implement it, all of them must return `true`. Interceptors that do not implement it are not consulted. When interception of a top-level result, array item or page item ends with an instance that a protecting interceptor does not report released, such as a masked copy returned by a later interceptor or another model’s instance, Arc runs that model’s protecting interceptors on it once more before checking it. For a protected Chronicle model this is one more Chronicle release per item, and the replacement must keep the model’s `@subject()` property or `id` unless it holds no protected value. * `interceptRawDocument(document, provenance)` transforms an untyped storage document marked with `markRawReadModelDocument()` as this model. If any interceptor for the model omits it, Arc fails the query rather than serve the raw document. A marked raw document may appear only as the top-level result, an array item, or a page item; anywhere else the query fails, whether or not the interceptor protects nested instances. Arc serves the interceptor’s result, or the document itself when the model’s interceptors return it unchanged; a different marked raw document returned by interception fails the query like a nested one. Arc never clears the stored document’s mark, so a document emitted again is intercepted again. ```ts @readModelInterceptor() class ProtectedAccount implements ReadModelInterceptor { readonly model = Account; intercept(account: Account): Account { return release(account); } isReleased(account: Account): boolean { return wasReleased(account); } interceptRawDocument(document: object): object { return releaseDocument(document); } } ``` The walk covers own enumerable values and skips typed arrays and buffers. It cannot see values reachable only through `toJSON()`, getters, or private fields, nor a document already copied into a new object, so return protected models directly rather than mapping them. The Chronicle integration registers a protecting interceptor for every read model that holds protected data; see [Chronicle compliance](/arc/backend/typescript/chronicle/compliance/). Not authorization An interceptor shapes data a caller is already allowed to see. Check access before the model is produced, in the query’s authorization or inside the query method. ## Next steps * [Render provider-backed queries](/arc/backend/typescript/queries/renderers/) covers provider-owned paging, which runs before interceptors. * [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/#queries-roles-and-ownership) shows how to restrict which rows a caller receives. # Render provider-backed queries > Register a scoped result renderer before read-model interception and ordinary paging. Your data provider can count and page a result itself, and your query method returns the provider’s query object, not an array. Arc’s in-memory paging cannot work with that value, and loading every row just to page it defeats the point. A `QueryRenderer` takes the provider-owned value and returns the page the provider cut, with its authoritative total. ## Write a renderer Implement `QueryRenderer` and mark the class with `@queryRenderer()`: ```ts import { ArcApplication, queryPage, queryRenderer, type QueryRenderer } from '@cratis/arc.core'; class PendingItems { /* Provider-specific query, not a result array. */ } @queryRenderer() class PendingItemsRenderer implements QueryRenderer { canRender(value: unknown): boolean { return value instanceof PendingItems; } render(value: unknown) { if (!(value instanceof PendingItems)) throw new Error('Unexpected query'); // Replace this example with your provider's count and page operation. return queryPage([{ id: 1 }], 1); } } const builder = ArcApplication.createBuilder(); builder.add(PendingItemsRenderer); // Or register a service and call addQueryRenderer(token). ``` `render` receives the value, the request’s `ExecutionContext`, and its `QueryOptions`. It returns data, or `queryPage(items, totalItems, sorting)` with the provider’s authoritative count and the sort it confirmed. Arc does not re-sort or re-page a `queryPage` in memory. ## Register it * `@queryRenderer()` lets `builder.add()` or `builder.discover()` register the class as a scoped service. * Without the decorator, register a service token with `builder.services.addScoped()` and pass it to `builder.addQueryRenderer()`. * With the lower-level `ArcServer`, list the tokens in `ArcOptions.queryRenderers`, and interceptor tokens in `readModelInterceptors`. Arc asks renderers in registration order, and **only the first** whose `canRender` accepts the value runs. With no matching renderer, Arc’s existing behavior for arrays, `queryPage` results, and scalars applies. ## How it runs The renderer and the [read-model interceptors](/arc/backend/typescript/queries/read-model-interception/) after it resolve in the same request or subscription scope. They also run for each observable emission, including a current-value snapshot. The [query pipeline](/arc/backend/typescript/queries/query-pipeline/#result-stages) shows where renderers run relative to interceptors, paging, and emission guards. A renderer does **not** automatically push SQL or MongoDB sorting and paging into a provider. It must own those operations, and the authoritative total, itself. ## Next steps * [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/) covers the request parameters a renderer reads from `QueryOptions`. * [Intercept read models](/arc/backend/typescript/queries/read-model-interception/) transforms the items a renderer returns. # Subscribe to an observable query > Receive live updates from an observable query over direct server-sent events, a direct WebSocket, or the installed @cratis/arc client and its multiplexed hub. Your server already streams changes from an observable query. Now a client has to listen. Pick the transport that matches the client you have: a browser `EventSource`, a raw WebSocket, or the `@cratis/arc` client with generated proxies. ## Before you start * A running Arc server that serves an observable query. The examples use the Tasks sample on `127.0.0.1:3000` and its `/api/tasks/listing/observe-all-tasks` route; [Get started](/arc/backend/typescript/getting-started/) shows how to run it. Replace the route with your own query’s route from `/.cratis/queries`. * To check the route from a terminal first, follow [Using observable queries with curl](/arc/backend/typescript/queries/using-observable-queries-with-curl/). ## Subscribe over direct server-sent events In a browser on the same origin: ```javascript const stream = new EventSource('/api/tasks/listing/observe-all-tasks'); stream.onmessage = event => console.log(JSON.parse(event.data).data); // Call stream.close() when you no longer need updates. ``` A direct SSE frame is `data: \n\n`, a full query result each time. Browser `EventSource` cannot set an `Authorization` header: authenticate with your host’s session cookie, never with the `.cratis-identity` display cookie. ## Subscribe over a direct WebSocket ```javascript const socket = new WebSocket(`ws://${location.host}/api/tasks/listing/observe-all-tasks`); socket.onmessage = event => { const message = JSON.parse(event.data); if (message.type === 'Data') console.log(message.data.data); if (message.type === 'Pong') console.log('Pong at', message.timestamp); }; socket.onopen = () => socket.send(JSON.stringify({ type: 'Ping', timestamp: Date.now() })); // Call socket.close() when you no longer need updates. ``` Direct WebSocket frames are `{"type":"Data","data":}`; a `Ping` receives a `Pong` with the same timestamp. The standalone Node host accepts upgrades on its own; framework adapters need a separate mount, described in [WebSockets](/arc/backend/typescript/hosts/websockets/). ## Use the installed client The published `@cratis/arc` client subscribes through generated `ObservableQueryFor` proxies over the [multiplexed hub](/arc/backend/typescript/queries/observable-query-demultiplexer/). The plain client defaults to the WebSocket hub. The `` provider from `@cratis/arc.react` defaults to the SSE hub instead; both accept anonymous connections. Each subscription still passes through query authorization, so an anonymous caller can only observe queries that permit anonymous access. The default `` configuration works for the [Tasks browser example](/arc/backend/typescript/getting-started/continue-in-the-browser/) without switching transports. For the direct transports above, set these before subscribing: ```typescript import { Globals } from '@cratis/arc'; import { QueryTransportMethod } from '@cratis/arc/queries'; Globals.queryDirectMode = true; Globals.queryTransportMethod = QueryTransportMethod.ServerSentEvents; // or QueryTransportMethod.WebSocket ``` Generated proxies carry the exact query name. The [proxy generator](/arc/backend/typescript/proxy-generation/) emits them for model-bound observable queries. ## When a subscription ends Every transport ends the same way: when the client closes or disconnects, when the source completes or errors, or when an [emission guard](/arc/backend/typescript/queries/observable-query-emission-guards/) denies an emission. [Subscription lifetime](/arc/backend/typescript/queries/observable-queries/#subscription-lifetime) describes what Arc cleans up. ## Next steps * [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/) explains the hubs that generated clients use. * [Frontend usage](/arc/backend/typescript/proxy-generation/frontend-usage/) shows generated proxies in a React application. * [Testing observable queries](/arc/backend/typescript/testing/observable-queries/) collects emissions in a spec, without a transport. # Using observable queries with curl > Read an observable query's snapshot, wait for its first result, and stream updates over server-sent events from a terminal. You can explore a live query without writing frontend code. These commands run against the Tasks sample on `127.0.0.1:3000`; replace the route with your own query’s route from `/.cratis/queries`. ## Read the current snapshot ```bash curl -i http://127.0.0.1:3000/api/tasks/listing/observe-all-tasks ``` | Status | Meaning | | --- | --- | | 200 | The source has a current value; `data` holds it and `isReady` is `true` | | 202 | No value yet; `isReady` is `false`. This is not a failure | | 403 | Not authorized, or an [emission guard](/arc/backend/typescript/queries/observable-query-emission-guards/) denied the snapshot | | 503 | A subscription limit is reached; retry after `Retry-After` | ## Wait for the first result ```bash curl 'http://127.0.0.1:3000/api/tasks/listing/observe-all-tasks?waitForFirstResult=true&waitForFirstResultTimeout=5' ``` For a pending source, `waitForFirstResult=true` waits for its first emission. The default wait is 30 seconds; `waitForFirstResultTimeout` accepts a positive number of seconds up to 120. A timeout answers 408; a source that completes before its first value answers 500. Invalid wait options answer 400. Booleans are case-insensitive (`True` works), and an unrecognized boolean is rejected, where Arc on .NET ignores it. Arc on .NET also accepts larger timeouts; this server bounds them to avoid retaining unlimited subscriptions. ## Stream updates ```bash curl -N -H 'Accept: text/event-stream' http://127.0.0.1:3000/api/tasks/listing/observe-all-tasks ``` `-N` turns off buffering. You receive one `data: ` frame now and one for every change; register a task in another terminal to see the next one. Press Ctrl+C to end the subscription; the server releases the source. ## Structured arguments A GET passes arguments in the query string. To send structured arguments to an observable query, use the [HTTP `QUERY` method](/arc/backend/typescript/queries/using-the-http-query-method/), which answers with a snapshot. ## Related * [Observable queries](/arc/backend/typescript/queries/observable-queries/) * [Introspection](/arc/backend/typescript/introspection/queries/) to list query routes # Using the HTTP QUERY method > Send query arguments, paging, and sorting as a JSON body with the HTTP QUERY method, and turn the method off when you only want GET. A query string flattens everything into text. When a query takes structured arguments, or you would rather not put values in a URL, send them in a body with the HTTP `QUERY` method. It is safe and idempotent like GET, and Arc accepts it on every query route by default. ## Send a QUERY request ```bash curl -X QUERY http://127.0.0.1:3000/api/tasks/listing/task-by-id \ -H 'content-type: application/json' \ -d '{"arguments":{"id":"1a638f8e-4444-4444-8888-a0b10cdd9977"}}' ``` Against the running Tasks sample, this answers 200 with the task in `data` and `Cache-Control: no-store`. ## The body The body is a JSON object with three recognized properties. A JSON `null` body, or `null` for any of these properties, is treated as absent. Unknown members of the envelope, paging, and sorting objects are ignored: ```json { "arguments": { "tags": ["urgent"] }, "paging": { "page": 0, "pageSize": 10 }, "sorting": { "field": "title", "direction": "desc" } } ``` | Property | Meaning | | --- | --- | | `arguments` | Parsed as JSON, so numbers, booleans, arrays, and objects keep their types. Names match case-insensitively, as with GET | | `paging` | `page` and `pageSize`. Nonpositive or missing `pageSize` means unpaged. | | `sorting` | `field` and `direction`; a direction without a field is ignored. | GET and QUERY have different paging rules; see the [request table](/arc/backend/typescript/queries/model-bound/paging/#request-parameters). Undeclared argument names are rejected by TypeScript (unlike .NET). Non-object envelope members and non-string sort fields or directions produce a 400 unreadable-body exception envelope. ## Turn it off Set `generatedApis: { enableQueryHttpMethod: false }` in the [options](/arc/backend/typescript/configuration/#the-arcoptions-tree) to accept GET only. A `QUERY` request then answers 405 with `Allow: GET`. ## Where QUERY does not appear OpenAPI 3.1 path items do not define a `QUERY` operation, so [`/openapi.json`](/arc/backend/typescript/open-api/) describes queries as GET only. Behind Fastify, `QUERY` is registered for command and query routes but not for the `/.cratis` endpoints. ## Related * [Query arguments](/arc/backend/typescript/queries/model-bound/query-arguments/) * [Arc HTTP contract](/arc/http-contract/) # Query validation > Validate a query's arguments together with an arguments model and QueryValidator, and know how concept rules apply without one. A search needs a non-empty term, and some rules only make sense across arguments together. Give the query an **arguments model** and a `QueryValidator` for it. Arc runs the validator before the query method, and answers 400 with the rule messages when it fails. ## Validate arguments together This excerpt comes from Arc’s own [validation specs](https://github.com/Cratis/Arc.TypeScript/tree/main/Source/Core/validation/for_ModelGraphValidator/given), with the imports rewritten to the package: ```typescript import { ConceptAs, field } from '@cratis/fundamentals'; import { QueryValidator, argument, query, readModel, validator } from '@cratis/arc.core'; export class Name extends ConceptAs { static readonly valueType = String; } export class SearchArguments { @field(Name) term!: Name; } @validator(SearchArguments) export class SearchArgumentsValidator extends QueryValidator { constructor() { super(); this.ruleFor(arguments_ => arguments_.term).notEmpty().withMessage('Term required'); } } @readModel() export class Search { @query({ argumentsModel: SearchArguments }, argument('term', Name)) static byTerm(term: Name): string { return term.value; } } ``` The arguments model’s `@field` declarations must match the query’s `argument(...)` descriptors. Add or discover the validator with the read model, as for command validators. The query model validator runs once, then Arc visits its fields for [concept validators](/arc/backend/typescript/concepts/#validate-a-concept-everywhere). ## Without an arguments model Arc still validates each supplied, non-null argument’s concept graph under that argument’s name. A `QueryValidator` needs an explicit arguments model to target. ## Differences from command validation * Queries always use `Warning` as the allowed severity; `X-Allowed-Severity` is ignored. See [Validation severity filtering](/arc/backend/typescript/commands/validation-severity-filtering/). * Literal, unconditional client-safe rules on an arguments model are emitted into [generated proxies](/arc/backend/typescript/proxy-generation/validation/). * Low-level `defineQuery` definitions use `validate` and `filters` with the `QueryFilter` type; see [Command filters](/arc/backend/typescript/commands/command-filters/). The rule vocabulary, conditions, services, and asynchronous rules are the same as for [command validation](/arc/backend/typescript/commands/command-validation/#rule-vocabulary). ## Related * [Query pipeline](/arc/backend/typescript/queries/query-pipeline/) * [Query arguments](/arc/backend/typescript/queries/model-bound/query-arguments/) # Reference > Look up the status and evidence of every Arc for TypeScript capability, the routes and headers it serves, its packages, and its wire format. These pages are for looking things up. Narrative guides link here instead of restating status or lists. | Page | Use it to | | --- | --- | | [Capability reference](/arc/backend/typescript/reference/capabilities/) | Check whether a capability exists, where its boundary lies, which spec proves it, and where Arc for TypeScript deliberately differs from Arc on .NET | | [HTTP contract reference](/arc/backend/typescript/reference/http-contract/) | Look up every route, method, header, and status code the server uses | | [Packages](/arc/backend/typescript/reference/packages/) | See what each package exports and what it needs | | [Wire format](/arc/backend/typescript/reference/wire-format/) | Check derived-type discriminators, naming, enums, and number encoding | | [Diagnostics](/arc/backend/typescript/reference/diagnostics/) | Find every lint rule, build error, validation reason, status code, and runtime surface that tells you why Arc behaves as it does | | [Glossary](/arc/backend/typescript/reference/glossary/) | Look up an Arc for TypeScript term and the decorator or function behind it | | [Configuration](/arc/backend/typescript/configuration/) | Look up every `ArcOptions` setting | | [Decorator reference](/arc/backend/typescript/decorators/) | Look up every decorator and parameter descriptor | | [Arc HTTP contract](/arc/http-contract/) | Read the language-neutral contract, maintained in the Arc repository | | [Arc capability matrix](/arc/capabilities/) | Compare Arc on .NET and Arc for Kotlin and Java | | [Arc glossary](/arc/glossary/) | Look up terms shared by every Arc implementation | # Capability reference > The status of every Arc capability in Arc for TypeScript, the TypeScript contract behind it, the spec or check that proves it, and where it deliberately differs from Arc on .NET. This page is the single status and parity reference for Arc for TypeScript. The shared [Arc capability matrix](/arc/capabilities/) covers C#, Kotlin, and Java; this page records, for TypeScript, whether each capability exists, where its boundary lies, and which spec, contract test, or sample proves it. The wire behavior every implementation follows is the [Arc HTTP contract](/arc/http-contract/). Other pages link here instead of restating status. Source preview, no full parity No package is published to npm, and Arc for TypeScript does not have full parity with Arc on .NET. Package names and APIs can still change. ## Status key | Status | Meaning | | --- | --- | | Supported | Implemented and covered by passing specs, contract tests, or runnable samples in this repository. Supported describes TypeScript behavior; it is not a parity claim. | | Bounded | Supported within the boundary the row states. | | Experimental | Implemented and checked, but the API and guarantees can change. | | Not implemented | Not available. | | Not applicable | Belongs to another platform’s host or toolchain. | Evidence paths are relative to the repository root. Spec folders follow `for_/when_`. Each row states the contract in brief; where a capability has more boundaries, a bullet under its table, headed with the capability name, lists them. ## Commands | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | Model-bound commands | Supported | `@command()` classes with Fundamentals `@field` declarations and an instance `handle()`; `defineCommand` with Zod remains the low-level path. See [Model-bound commands](/arc/backend/typescript/commands/model-bound/). | `Source/Core/commands/modelBound/for_compileCommand`, `Source/Core/for_ArcApplicationBuilder/when_building_model_bound_artifacts`, `Samples/Tasks/Features/Tasks/Registration/for_RegisterTask` | | Artifact discovery | Bounded | `builder.add(...)` takes an explicit catalog; `discover()` imports exported artifacts from a dedicated file-URL folder, derives namespaces from paths, and rejects conflicting namespaces and mixed JS/TS output. No bundler or assembly scanning. | `Source/Core/for_ArcApplicationBuilder/when_discovering_artifacts`, `.../when_adding_artifacts` | | Validate without executing | Supported | `POST /validate` runs authorization, global command filters, and validation only; a command named `Validate` executes on its own route. | `Source/Core/for_ArcServer/when_validating_a_command`, `ContractTests/Http/conformance.test.mjs` | | Global command filters | Bounded | Scoped `AuthorizationCommandFilter` services always precede `CommandPipelineFilter` services, independent of registration order. Context-value/key resolution on valid input precedes context-aware authorization filters; these providers/resolvers must not perform protected mutations. Decorators and builder registration support both; result fragments merge and stop on failure on execute and `/validate`. Express, Fastify, and Hono execute and `/validate` denial/allow are paired with .NET; TypeScript traces verify ordering and that denial skips validator construction and handling. Existing per-definition `CommandFilter` callbacks still aggregate. See [Command filters](/arc/backend/typescript/commands/command-filters/). | `Source/Core/for_ArcApplicationBuilder/when_running_command_filters`, `Source/Core/for_ArcServer/when_authorizing_by_operation_name`, `ContractTests/Http/conformance.test.mjs` | | `provide()` and outcomes | Supported | `provide()` runs after validation and may short-circuit with `rejected(...)` or `denied(...)`. Only helper-created values are outcomes; `isOutcome` recognizes them. See [Command outcomes](/arc/backend/typescript/commands/command-outcomes/). | `Source/Core/for_ArcServer/when_providing_a_command`, `Source/Core/for_ArcApplicationBuilder/when_providing_a_model_bound_command`, `Source/Core/commands/for_Outcome` | | Several return values and response value handlers | Bounded | `tuple(...)` flattens branded groups; at most one unhandled value becomes the response, and scoped `CommandResponseValueHandler`s process every other value in deterministic name order. | `Source/Core/commands/for_tuple`, `Source/Core/for_ArcServer/when_processing_command_response`, `Source/Core/for_ArcApplicationBuilder/when_registering_a_response_handler` | | Execution scopes | Supported | Low-level `scopes` complete once in reverse order, including a scope whose `begin` threw; a failed completion removes the response. Model-bound commands use command execution runners instead. | `Source/Core/for_ArcServer/when_beginning_a_command_scope`, `.../when_completing_a_command_scope`, `Source/Core/for_ArcApplicationBuilder/when_executing_command_runners` | | Command operations | Bounded | `CommandOperation` declarations are preflighted, executed in order, and compensated in reverse after a known uncommitted failure; `Unknown` and `Mixed` fail closed. No distributed transaction, durable recovery, or crash guarantee. See [Command operations](/arc/backend/typescript/commands/operations/). | `Source/Core/for_ArcServer/when_executing_an_operation`, `.../when_an_operation_fails`, `.../when_a_commit_is_unknown`, `.../when_declaring_nested_operations` | | Command context and keys | Bounded | `CommandContext` carries the command, a key resolved once, case-insensitive values, and request identity. `@key()`, `getKey()`, and scoped `CommandKeyResolver`s; `abortSignal()`, `commandContext()`, and `provided(Type)` markers. See [Command context](/arc/backend/typescript/commands/command-context/). | `Source/Core/for_ArcServer/when_resolving_command_keys`, `Source/Core/for_ArcApplicationBuilder/when_resolving_command_keys`, `.../when_resolving_command_arguments` | | Command read models by key | Bounded | `commandReadModel(Type)` on `handle()` or `provide()` loads by command key through a registered resolver; MongoDB, Chronicle, and Drizzle provide resolvers. Equal ownership claims fail. | `Source/Chronicle/for_ChronicleReadModelForCommandResolver`, `Source/MongoDB/for_MongoCollection`, `Source/Drizzle/for_DrizzleReadModelForCommandResolver` | | Calling from code | Supported | `executeCommand`, `execute(instance)`, `performQuery`, and `handle(request)`. See [Calling commands from code](/arc/backend/typescript/commands/calling-commands-from-code/). | `Source/Core/for_ArcServer/when_executing_a_command`, `Source/Core/for_ArcApplicationBuilder/when_executing_an_instance` | | Controller-based commands and queries | Not applicable | ASP.NET Core MVC only. | | * **Several return values and response value handlers.** Ordinary arrays are not flattened. At generation time, Chronicle event types/arrays and integration wrappers plus Arc operations are omitted from command responses. Each alternative path through an `Outcome`, union, alias, promise, or tuple must have at most one client-visible value, and all paths must use the same client decoder and cardinality; otherwise generation fails with guidance to return one response DTO with an application-owned status field. There is no general client-visible response union or implicit branch discriminator. The paired .NET 22.45.0 fixture pins DTO, primitive, validation, denial, arbitrary error DTO (a 200 success), and tuple alternatives. TypeScript matches those HTTP envelopes, but unlike .NET’s lossy proxy generator it rejects distinct DTO or cardinality alternatives rather than silently picking one decoder; see [Command outcomes](/arc/backend/typescript/commands/command-outcomes/). * **Command read models by key.** Drizzle command injection requires exactly one column-level `.primaryKey()` with an Arc `@field` on that key; without `@field` on an existing column-level key, the model can serve queries but injection fails at build (no resolver claims it). Table-level `primaryKey({ columns })` is not recognized, and `withDrizzle` rejects such a model at registration. Custom codec columns bind typed keys; plain columns bind primitives, with invalid GUID and integer keys rejected. Command injection is tested against SQLite, live MySQL 8.4, and live PostgreSQL 16 with node-postgres. PostgreSQL checks typed GUID and concept keys, tenant isolation, and missing required and optional rows. A type registered with both Chronicle and Drizzle fails at build when injected with `commandReadModel` (two resolver claims). Chronicle additionally supports `readModelForValidation(Type, { optional: true })` in model-bound command validators. ## Queries | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | Model-bound queries | Bounded | `@readModel()` with static `@query(...)` methods and complete ordered `argument`, `service`, and `queryOptions` descriptors; with generated metadata, a bare `@query()` infers ordered arguments, concrete services, and observable returns; without it, legacy emitted metadata infers only class-valued services. Identity includes namespace, read model, and method. | `Source/Core/queries/modelBound/for_compileQueries`, `Source/Core/for_ArcApplicationBuilder/when_serving_model_bound_http`, `Samples/Tasks/Features/Tasks/Listing/for_TaskItem` | | Global query filters | Bounded | Scoped `AuthorizationQueryFilter` and `QueryPipelineFilter` services return result fragments; authorization precedes validator and performer dependency construction. They run once at observable admission, not per emission, including opt-in health. GET/`QUERY`/snapshot/direct SSE filter denial and allow are paired with .NET on Express, Fastify, and Hono. Direct WebSocket denial and allow, plus continued service to a second SSE/WS subscription after an early disconnect, are checked on all three TypeScript adapters; the .NET fixture does not expose a direct WebSocket upgrade. TypeScript traces verify authorization-before-ordinary-before-validation and that denial skips dependency construction and the producer. See [Query filters](/arc/backend/typescript/queries/query-filters/). | `Source/Core/for_ArcApplicationBuilder/when_running_query_filters`, `Source/Core/for_ArcServer/when_filtering_observable_health`, `ContractTests/Http/conformance.test.mjs` | | GET and HTTP `QUERY` | Supported | `QUERY` answers with `Cache-Control: no-store` and can be turned off with `generatedApis: { enableQueryHttpMethod: false }`. | `Source/Core/for_ArcServer/when_handling_a_query`, `ContractTests/Http/conformance.test.mjs` | | Argument binding | Supported | Case-insensitive names, number and boolean conversion on GET, repeated keys for array arguments. | `Source/Core/for_ArcServer/when_handling_a_query`, `Samples/Tasks/Features/Tasks/Listing/for_TaskItem/when_performing/with_a_concept_argument.ts` | | Paging and sorting | Supported | Arrays are sorted and paged in memory; a provider returns `queryPage(items, totalItems, sorting?)`. Page offsets clamp to signed int32. See [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/). | `Source/Core/for_ArcServer/when_sorting_a_query`, `Source/Core/queries/for_queryRendering`, `Samples/Tasks/Features/Tasks/Listing/for_TaskItem/when_performing/with_sorting_and_paging.ts` | | Renderers and read-model interceptors | Bounded | Ordered scoped `QueryRenderer`s own provider results; exact-class `ReadModelInterceptor`s transform snapshots and each emission, including HTTP snapshots. No automatic provider pushdown. | `Source/Core/queries/for_queryRendering`, `Source/Core/for_ArcApplicationBuilder/when_registering_read_model_interceptors` | | Services and dependencies | Supported | Class and `serviceToken` tokens with singleton, scoped, and transient lifetimes; the build preflights declared graphs. Scopes snapshot every declared execution-context field at creation (including inherited getters) but retain the original principal for ordinary factories. Trusted hosts can borrow a live scope only when its principal is strictly plain data; borrowed work uses a separate deep-frozen principal copy and may override only correlation and link an additional signal. This is not authorization. Host integrations can register shutdown participants to stop admission and drain external work before Arc disposes scopes and singletons. See [Dependency injection](/arc/backend/typescript/dependency-injection/). | `Source/Core/dependencyInjection/for_ServiceRegistry`, `.../for_ServiceScope`, `Source/Core/for_ArcServer/when_borrowing_a_scope`, `Source/Core/for_ArcApplicationBuilder/when_resolving_model_bound_services`, `yarn test:legacy-decorators` | * **Paging and sorting.** GET rejects nonpositive integer `pageSize` with a paging rule; nonnumeric and out-of-int32 GET paging values default, while leading zeros, `+`, and surrounding whitespace parse as integers. Structured `QUERY` treats nonpositive sizes as unpaged and rejects invalid sort directions with an owning member. ## Observable queries | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | Observable definitions and HTTP snapshots | Bounded | `{ observable: true }` model-bound queries and `defineObservableQuery` accept RxJS `BehaviorSubject`, `ReplaySubject`, `Subject`, `Observable`, async iterables, and structural subscribables. RxJS is an optional peer of core for async-iterable-only consumers. | `Source/Core/for_ArcServer/when_handling_observable_snapshot`, `Source/Core/queries/observable/for_CurrentValueSubject`, `Source/Core/for_ArcApplicationBuilder/when_serving_model_bound_observables` | | Direct SSE and WebSocket | Bounded | The query route streams direct result frames through the Express, Fastify, and Hono Node adapters and the standalone host; generated installed-client subscriptions receive initial and later results. | `Source/Core/queries/observable/for_directWebSocket`, `ContractTests/Client/observable-direct-sse.test.mjs`, `ContractTests/Client/observable-upgrade-lifecycle.test.mjs` | | Multiplexed WebSocket and SSE hubs | Bounded | `/.cratis/queries/ws` and `/.cratis/queries/sse` accept anonymous connections, with revisions, configurable keep-alive, and tombstones on all three adapters and the installed 22.45.0 client (including the React provider’s default SSE transport). Query authorization is checked per subscription. | `Source/Core/queries/observable/for_HubConnection`, `Source/Core/for_ArcServer/when_subscribing_to_sse`, `.../for_SseHubTransport`, `.../for_SubscriptionRevisions`, `ContractTests/Client/observable-hub.test.mjs` | | Full, delta, and legacy transfer | Bounded | `full`, `delta` (initial full, then change sets), and legacy full plus change set. The installed client does not rebuild delta arrays in subscription callbacks. | `Source/Core/queries/observable/for_ObservableTransfer` | | Emission guards | Supported | Scoped guards `Allow`, `Suppress`, or `DenyAndTerminate` every rendered result; failures deny and are logged. | `Source/Core/queries/observable/for_ObservableQuerySession`, `Source/Core/for_ArcServer/when_opening_observable_query` | | Query health | Supported, with a deliberate difference | `query: { enableObservableHealth: true }` exposes the authenticated caller’s own hub connections only. Global query filters run at health admission, not per emission. .NET exposes broader anonymous metadata. | `Source/Core/for_ArcServer/when_reporting_caller_scoped_health.ts`, `.../when_filtering_observable_health`, `.../when_handling_observable_health`, `Source/Core/queries/observable/for_FilteredHealthSource` | | Resource limits | Supported, configurable | Subscription, connection, frame, queue, tombstone, handshake, and shutdown limits; exhaustion answers 503 or WebSocket 1013/1009. No request-rate limit. | `Source/Core/queries/observable/for_ObservableLimits`, `ContractTests/Client/observable-capacity.test.mjs` | * **Observable definitions and HTTP snapshots.** `CurrentValueSubject` remains supported but is deprecated. BehaviorSubject current values answer 200; sources without readable current values answer 202; bounded waits answer 408 or 500. * **Multiplexed WebSocket and SSE hubs.** SSE control requests must match the opener’s authenticated identity or anonymous state and tenant; anonymous callers are also matched by peer address when available, unlike .NET. Anonymous per-caller connection and subscription budgets are kept per tenant and peer address. SSE streams send `Cache-Control: no-cache`. ## Validation | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | Command and query validators | Bounded | `@validator(Target)` classes or validators with a generated target extending `CommandValidator` or `QueryValidator` with constructor-authored `ruleFor` rules; query validators target an explicit `argumentsModel`. One validator per exact target. | `Source/Core/validation/for_validator`, `.../for_ModelGraphValidator`, `.../for_RuleBuilder`, `Samples/Tasks/Features/Tasks/Registration/for_RegisterTask/when_validating` | | Concept and model validators | Bounded | `ConceptValidator` and `ModelValidator` apply through declared `@field` graphs, with collection paths; `ignoreConceptRules()` skips only the direct member. No getter reflection or DataAnnotations. | `Source/Core/validation/for_ModelGraphValidator` | | Result shape and failures | Supported | Results carry `severity`, `message`, `members`, `reason`, and optional `state` and `reasonDetail`. `ValidationResult.information/warning/error(message, options?)` constructs results with `members`, `state`, `reason`, and `reasonDetail`; optional metadata reaches command and query HTTP envelopes. Malformed input is 400 `malformedRequest`; a throwing validator is 400 `validatorFailed`, logged, without exception text. | `Source/Core/validation/for_ValidationResult`, `Source/Core/for_ArcServer/when_handling_a_command`, `Source/Core/for_ArcServer/when_handling_a_query`, `ContractTests/Http/conformance.test.mjs` | | [Severity filtering](/arc/backend/typescript/commands/validation-severity-filtering/) | Supported, with a deliberate difference | HTTP caps `X-Allowed-Severity` at Warning; see [Deliberate differences](#deliberate-differences). | `Source/Core/for_ArcServer/when_selecting_http_severity`, `ContractTests/Http/conformance.test.mjs` | | Rules shared with the client | Bounded | The proxy generator emits literal, unconditional client-safe rules; server-only rules produce diagnostics. See [Validation rules in proxies](/arc/backend/typescript/proxy-generation/validation/). | `Source/Tools/ProxyGenerator/for_renderRecordedRules`, `Source/Tools/ProxyGenerator/for_analyzeSource` | ## Security, identity, tenancy, and correlation | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | Declared authorization | Supported | `@authorize`, `@roles`, `@allowAnonymous`, or `authorization`; roles in one declaration are OR, stacked declarations on one target AND. Explicit query method declarations replace the read-model class declaration; command authorization is class-only. Contradictions fail at startup. See [Authorizing commands and queries](/arc/backend/typescript/authorizing-commands-and-queries/). | `Source/Core/for_ArcServer/when_handling_a_protected_command`, `.../when_handling_a_protected_query`, `Source/Core/for_ArcApplicationBuilder/when_authorizing_a_model_bound_command` | | Per-request authorization | Supported | `authorize(input, context)` runs after the schema; allowed severity never affects it. | `Source/Core/for_ArcServer/when_handling_a_protected_command` | | Named policies and schemes | Bounded | Function or scoped class policies; named Arc handlers selected per operation. Schemes on observable queries fail at build. See [Authorization policies and schemes](/arc/backend/typescript/core/authorization/). | `ContractTests/Http/conformance.test.mjs` (policy allow and deny against .NET 22.45.0), `Source/Core/for_ArcServer/when_authorizing_named_schemes.ts`, `.../when_authenticating_a_handler_scheme.ts`, `.../when_rejecting_observable_scheme_configuration.ts` | | Authentication handlers | Supported | Ordered chain; the first recognizing handler decides, and a failure is terminal with 401. | `Source/Core/authentication/for_authenticate` | | JWT bearer and EasyAuth | Supported, opt-in | `jwtBearer()` verifies signatures against an HTTPS JWKS with pinned issuer, audience, and algorithms (TypeScript only). `microsoftIdentityPlatform()` maps forwarded EasyAuth headers like .NET and requires a trusted ingress. See [Authentication](/arc/backend/typescript/core/authentication/). | `Source/Core/authentication/for_jwtBearer`, `.../for_microsoftIdentityPlatform`, `Source/Core/for_ArcServer/when_receiving_non_bearer_authorization.ts` | | Native principal | Supported, opt-in | `nativePrincipal: true` plus a trusted adapter callback; mutually exclusive with handlers. | `Source/Express/for_identityHosts`, `ContractTests/Client/observable-native-auth.test.mjs` | | [Identity details](/arc/backend/typescript/identity/) | Supported, opt-in | Zod or model-bound `detailsType` providers, discovered or explicit; `/.cratis/me` answers 401, 403, or 200 with a display cookie of at most 4096 bytes. | `Source/Core/for_ArcServer/when_handling_identity_request`, `.../when_issuing_identity_cookies`, `Source/Core/for_ArcApplicationBuilder/when_discovering_identity_details.ts` | | Development users and tenants | Supported, opt-in | Fixture providers require `development: true` and are capped at 100 entries and 32 KiB. Their endpoints follow the [discovery access policy](/arc/backend/typescript/introspection/#production-access), anonymous only in Development by default. | `Source/Core/for_ArcServer/when_discovering_development_tenants`, `.../when_resolving_development_tenant.ts` | | [Tenant resolution](/arc/backend/typescript/tenancy/) | Bounded | Default header, authoritative `tenancy.resolve`, a .NET-compatible `tenancy.resolverType`, and ordered header, query, claim, fixed, and strict subdomain sources with `required` and membership checks. | `Source/Core/for_ArcServer/when_configuring_tenancy`, `.../when_resolving_tenants`, `.../when_checking_tenant_membership`, `.../when_resolving_tenant_claims` | | Correlation IDs | Supported | Valid non-zero UUIDs are reused, others replaced; the header name is configurable. W3C trace context stays host-owned. | `ContractTests/Http/conformance.test.mjs` | | Exception redaction | Supported | Outside development, HTTP results carry a generic message and no stack trace; direct calls are not redacted. | `Source/Core/for_ArcServer/when_logging_a_failure`, `ContractTests/Http/conformance.test.mjs` | ## Proxies, introspection, and tooling | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | TypeScript proxy generation | Bounded | `arc-proxygenerator` reads decorated source and emits commands, queries, observable queries, models, hooks, derived types, and client-safe rules for the published `@cratis/arc` 22.45.0 client in strict Bundler mode. Full .NET template parity remains unverified. See [Proxy generation](/arc/backend/typescript/proxy-generation/). | `Source/Tools/ProxyGenerator/for_analyzeSource` (including duplicate-name tsc compilation), `.../for_renderSource`, `.../for_SourceTypeResolver`, `yarn test:client-generation` (`ContractTests/Client/source-generation.test.mjs`) | | Low-level client manifest | Bounded | `exportClientManifest` plus the positional JSON CLI for `define*` definitions with explicit `clientOutput`: flat DTOs, basic inputs, and command responses. No hooks, nested DTOs, or shared rules. See [Low-level manifest](/arc/backend/typescript/proxy-generation/low-level-manifest/). | `Source/Core/introspection/for_exportClientManifest`, `ContractTests/Client/generation.test.mjs` | | Introspection | Supported | `/.cratis/commands`, `/.cratis/queries`, and `/.cratis/identity-details/schema` describe input JSON Schema under the [discovery access policy](/arc/backend/typescript/introspection/#production-access): anonymous in Development, authenticated elsewhere by default. `introspection.enabled: false` (`Cratis:Arc:Introspection:Enabled`) disables catalogs and HTTP OpenAPI, not identity discovery or in-process metadata. See [Turn discovery off](/arc/backend/typescript/introspection/#turn-discovery-off). | `Source/Core/for_ArcServer/when_introspecting_queries.ts`, `.../when_discovery_is_disabled`, `Source/Express/for_identityHosts/when_discovery_is_disabled.ts`, `ContractTests/Http/conformance.test.mjs` | | OpenAPI | Supported | `/openapi.json` is OpenAPI 3.1 with input schemas, result envelopes, generated typed result schemas when metadata is registered, command `POST /validate` with an untyped envelope, query paging/sorting options only for declared array or `queryPage` results, and bearer security declarations for configured authentication handlers. Unknown query return cardinality does not advertise paging even though the runtime can page an array or `queryPage` result; renderer-backed (`QueryRenderer`) queries aren’t automatically advertised as pageable. `info.version` can be set with `generatedApis.openApiVersion` (default `0.1.0`). See [OpenAPI](/arc/backend/typescript/open-api/). | `Source/Core/for_ArcServer/when_describing_observable_query_in_openapi.ts`, `.../when_serving_openapi`, `Source/Core/openApi/for_renderOpenApi`, `ContractTests/Client/source-generation.test.mjs` (JSDoc summary over HTTP) | | Concepts, derived types, and wire format | Bounded | Primitives, `Date`, Fundamentals `Guid`, `DateOnly`, `TimeOnly`, `TimeSpan`, concepts, nested models, arrays, and field modifiers; `_derivedTypeId` polymorphism with `oneOf` schemas; .NET-style acronym-preserving camel case. See [Wire format](/arc/backend/typescript/reference/wire-format/). | `Source/Core/reflection/for_wireSchema`, `Source/Core/for_ArcServer/when_binding_a_polymorphic_command`, `ContractTests/Client/polymorphic.test.mjs` | | Code analysis | Bounded | `@cratis/eslint-plugin-arc-core` for ESLint 10: .NET-mapped ARC rules and TypeScript-only binding rules, with `recommended` and `recommended-type-checked` presets. No automatic fixes. See [Code analysis](/arc/backend/typescript/code-analysis/). | `Source/CodeAnalysis/for_rules`, `yarn lint:tasks:arc` | | Build-time checks | Bounded | `add()` rejects undecorated classes; `build()` rejects decorators with no effect, missing services, cycles, and captive lifetimes. Generated binding metadata checks runtime-verifiable shapes at registration; `--check-metadata` catches source-only changes. | `Source/Core/for_ArcApplicationBuilder/when_building`, `yarn test:decorator-types` | | Screenplay generation | Not applicable | .NET only. | | * **TypeScript proxy generation.** Optional server metadata infers service and argument bindings with an explicit `--metadata` option. Same-named models in distinct namespaces, decorated identity-provider result types, and symbol-resolved Chronicle event/operation command return filtering are supported, as are static query HTTP method and warning preferences. Source-only identity configuration also remains unverified. Identity details models outside the artifacts root are skipped with a diagnostic. ## Persistence and Chronicle | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | [MongoDB](/arc/backend/typescript/mongodb/) | Bounded | Tenant-scoped collections with BSON mapping, .NET-compatible naming policies, database-side paging, change streams, and command read models. No cross-store transactions. See [how it is checked](#mongodb-checks). | `Source/MongoDB/for_MongoCollection`, `.../for_MongoDocumentCodec`, `.../for_MongoReadModels`, `.../for_withMongoDB`, `bash Source/MongoDB/run-integration.sh` (live replica set, Docker) | | [SQL with Drizzle](/arc/backend/typescript/sql/) | Bounded | Tenant-scoped Drizzle handles, column codecs, and SQL count, sort, and page for model-bound queries. SQLite, PostgreSQL 16, and MySQL 8.4 tested with real databases. MySQL live coverage includes tenant routing, codecs, stable paging, limits, sort rejection, and command lookup. PostgreSQL command lookup with node-postgres covers tenant routing, typed keys, and missing rows. No migrations, change tracking, or transactions. See [how it is checked](#sql-checks). | `Source/Drizzle/for_DrizzleReadModelForCommandResolver`, `Source/Drizzle/for_DrizzleReadModels`, `.../for_ColumnCodec`, `.../for_DrizzleModelCodec`, `.../for_withDrizzle`, `bash Source/Drizzle/run-integration.sh` (live PostgreSQL 16 and MySQL 8.4, Docker) | | [SQL table observation](/arc/backend/typescript/sql/observing-tables/) | Experimental | `DrizzleObservation.InProcess` observes explicitly announced changes in one process. `PostgreSQLNotify` uses application-installed statement triggers and one dedicated listener per active tenant for committed cross-process invalidation. Listener loss pauses reads; bounded reconnect (five delayed attempts) revalidates live relations and forces fresh snapshots, including unused primes. Exhaustion errors subscriptions; later subscriptions may start fresh. Eventual committed state, not durable intermediate-state delivery or atomic page snapshots. No MySQL/SQLite cross-process observation or polling. | `Source/Drizzle/for_DrizzleReadModels/when_observing`, `.../when_serving_a_sqlite_observation`, `.../when_observing/with_postgres.integration.ts`, `.../for_DrizzleHandle`, `bash Source/Drizzle/run-integration.sh postgresql` | | [Chronicle](/arc/backend/typescript/chronicle/) | Experimental | Not published to npm. `withChronicle` appends returned model-bound events through a response value handler, with routing, subject, and causation resolved from the command. Full .NET parity is unverified. See [how it is checked](#chronicle-checks). | `Source/Chronicle/for_ChronicleResponseHandler`, `.../for_ChronicleUnitOfWork`, `.../for_ChronicleReadModelForCommandResolver`, `.../for_reactorCommandResultHandler`, `.../for_AggregateRoot`, `Source/Chronicle/testing/for_ChronicleCommandScenario`, `bash Source/Chronicle/run-integration.sh` (live kernel, Docker) | | [Chronicle compliance](/arc/backend/typescript/chronicle/compliance/) | Bounded | Subject resolution on appends and `@notAudited` and `@pii` exclusion from the causation chain. Arc releases protected models decoded into the exact read-model class, and raw `MongoReadModels` documents typed with `readModel`, at the query edge. A failed release fails the result. | `Source/Chronicle/for_ChronicleReadModelInterceptor`, `Source/Core/queries/for_interceptReadModel`, `Source/Chronicle/for_ChronicleReadModelForCommandResolver/when_resolving_a_private_projection`, `Source/Chronicle/Integration/live.test.mjs` | | Reactor replay exclusion | Bounded (SDK 6.9.0+) | The SDK replays reactors by default and supports class- or handler-level `@onceOnly()` and alternate `@replay()` handlers. Arc executes returned commands under those SDK rules; type-checked `ARCCHR0006` warns on returned commands without a replay decision. Failed-partition recovery can re-deliver effects even with `@onceOnly()`. See [Reactors](/arc/backend/typescript/chronicle/reactors/). | SDK decorators; `Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts` checks the lint rule; `Source/Chronicle/Integration/LiveArtifacts.ts` and `bash Source/Chronicle/run-integration.sh` exercise a once-only reactor returning a command on normal delivery, not replay exclusion | | [Chronicle code analysis](/arc/backend/typescript/chronicle/code-analysis/) | Bounded | `ARCCHR0003` checks reactor store fields initialized from `this.client`/`this.runtime` (ownership is not proven); type-checked `ARCCHR0006` flags returned commands from live handlers without a replay decision; `ARCCHR0007` flags direct default-log appends from command `handle`/`provide`, including injected Chronicle services; `ARCCHR0009` checks unmasked secret-looking fields; type-checked `ARCCHR0010` flags Guid values beside direct decorated events on keyless commands. Other .NET diagnostics are inapplicable, checked at runtime, or require review. | `Source/CodeAnalysis/for_rules/when_linting_artifacts/with_chronicle_rules.ts`, `Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts` | | Transactions and units of work | Experimental | Chronicle stages returned and aggregate-applied events from nested commands in one tenant, correlation, and event store, and sends one `appendMany` after the outer command succeeds. It participates in Arc command-operation failure handling; it is not a transaction across stores or immediate SDK appends. | `Source/Chronicle/for_ChronicleUnitOfWork`, `Source/Chronicle/for_ChronicleCommandScope` | | Event source definitions | Experimental | `@eventSourceDefinition` on a command or aggregate routes events through a registered Chronicle definition and stream: startup validation, per-event source and stream replacement as a unit, definition-derived concurrency when no explicit scope or flags are set, and aggregate rehydration limited to the declared source and stream. Needs `@cratis/chronicle` 6.49.0 or later; string routing works with older SDKs. Verified against Chronicle kernel 19.30.0 and SDK 6.49.0. Screenplay grammar and generation for definitions are not part of this package. | `Source/Chronicle/for_ChronicleResponseHandler/when_routing_through_an_event_source_definition`, `Source/Chronicle/for_withChronicle/when_validating_event_source_definitions`, `Source/Chronicle/for_AggregateRoot/when_declaring_an_event_source_definition`, `ARC_CHRONICLE_TEST_SUITE=event-source-routing bash Source/Chronicle/run-integration.sh` | * **MongoDB.** Also [joined observation](/arc/backend/typescript/mongodb/joined-observe/), a [scoped watcher](/arc/backend/typescript/mongodb/change-stream-watcher/), [GeoJSON geometry](/arc/backend/typescript/mongodb/geospatial/), bounded transient read retries, MongoDB driver metrics for Arc-owned clients, and `Cratis:MongoDB:{Server,Database}` configuration binding. No durable watcher checkpoint; nonresumable stream failures terminate subscriptions. .NET’s process-wide watcher, general-purpose resilience interceptors, and comprehensive metrics for supplied clients are not implemented. * **SQL with Drizzle.** `bash Source/Drizzle/run-integration.sh` exercises live PostgreSQL and MySQL for existing SQL reads and command lookup, not observation. In-process observation is checked using SQLite (`sql.js`), gated race and burst specs, and SSE/GET through Express, Fastify, and Hono. Command read models load by a single column-level `.primaryKey()` with `@field` in the tenant scope; models without `@field` on their column-level key still serve queries but cannot be injected into commands. Tables without a column-level primary key are rejected at registration. Custom columns bind typed keys, plain columns primitives. Command read models are tested against SQLite, live MySQL 8.4, and live PostgreSQL 16 with node-postgres. PostgreSQL coverage includes typed GUID and concept keys, tenant isolation, and missing required and optional rows. * **Chronicle.** It also resolves Chronicle read models by command key and in validators, batches nested returned events, and executes Arc commands returned from reactors through the SDK reactor result hook. SDK 6.49.0 loads in native Node ESM. Keyed aggregates and returned reactor commands are experimental. * **Chronicle compliance.** Mark projected read-model properties `@pii()` to encrypt them at rest; event-only marking does not protect the materialized field. Chronicle kernel reads already release values (including command injection), and Arc skips releasing those instances twice. For protected Chronicle models decoded into the exact read-model class by `MongoCollection`, Arc releases at the query edge, including snapshots, pages, and observable emissions. Raw `MongoReadModels` documents typed with `readModel` are released with the request’s tenant and each document’s subject: `subjectFor(document)` when given, otherwise the stored `__subject`, otherwise a string or numeric `_id`. It must match any stored `__subject` and the model’s `@subject()` or `id`. Kernel bookkeeping fields are stripped. The path fails closed on undeclared fields, non-JSON BSON values (including `Guid` fields stored as `Binary`), per-property `__subjects`, a subject or tenant mismatch, typed documents nested in another shape, and MongoDB projections. Unreleased instances of a protected class nested in another returned shape, such as a joined `select` result, fail the query. Specs check snapshots, pages, and observable SSE through Express, Fastify, and Hono against a Chronicle substitute; the kernel integration reads a real materialized document through `MongoReadModels`. Untyped raw documents, typed documents of models not registered with Chronicle, codec-selected derived subtypes, `MongoDBWatcher.changes()` payloads, documents hidden behind `toJSON()`, getters, or private fields, and DTOs, mapped objects, or copies are served as stored and need explicit `readModels.release` on the tenant store. Nested, array-item, class-level PII and `@encrypted()` security metadata are detected. A directly read protected model needs `@subject()` or `id` matching the event subject to release; one without either is served only when it holds no protected value, and fails otherwise. The kernel integration checks raw MongoDB ciphertext, Chronicle delivery, and Arc query-edge release for a direct MongoDB read. ## Testing | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | [Pipeline scenarios](/arc/backend/typescript/testing/) | Bounded | `CommandScenario`, `QueryScenario`, and `ObservableQueryScenario` run decorated artifacts through real pipelines with wire encoding and fakes; `ArcScenario` covers low-level definitions and HTTP. `ChronicleQueryScenario` resolves pinned, reducer-built, or supported flat projection-backed keyed read models in memory, but not lists or subscriptions. A [node:test recipe](/arc/backend/typescript/testing/node-test/) runs one command scenario with disposal. | `Source/Testing/for_CommandScenario`, `.../for_QueryScenario`, `.../for_ObservableQueryScenario`, `.../for_ArcScenario`, `.../for_withCommandAssertions`, `Source/Chronicle/testing/for_ChronicleCommandScenario`, `Source/Chronicle/testing/for_ChronicleQueryScenario`, `Samples/Library/kernel-scenarios.test.mjs` (live kernel, Docker), `yarn check:node-test` | * **Pipeline scenarios.** `ChronicleCommandScenario` records command-produced events separately from seeded event history and resolves reducer-backed and [supported flat projection-backed](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/testing.md#projection-capabilities) command read models with the SDK’s `ReadModelScenario`; pins take precedence. `ChronicleQueryScenario` uses the same seed store for keyed Chronicle query reads and rejects unsupported lists and observations. Unsupported projection operations, aggregate replay, and observer-driven updates require `ChronicleKernelScenario`, which seeds events and runs against a live kernel. The in-memory scenario does not enforce concurrency or update reduced state after command appends. ## Hosting and observability | Capability | Status | TypeScript contract | Evidence | | --- | --- | --- | --- | | Express 5, Fastify 5, and Hono 4 adapters | Supported | `cratisArc` accepts an `ArcServer` or built `ArcApplication`: Express middleware plus listener attach; Fastify plugin registers HTTP and WS in either shared-plugin order; Hono sub-app plus Node serving helper. The host keeps listener ownership. See [Host adapters](/arc/backend/typescript/hosts/). | `Source/Express/for_cratisArc`, `Source/Fastify/for_cratisArc`, `Source/Hono/for_cratisArc`, `ContractTests/Client/observable-origin.test.mjs` | | Node configuration and Fetch dispatch | Bounded | The default `@cratis/arc.core` entry keeps Node file configuration, discovery, HTTP host, static files, and WebSocket upgrades. The separate `@cratis/arc.core/fetch` entry registers artifacts without file discovery or configuration and serves commands, queries, and SSE. Next.js App Router `next build && next start` on Node.js is checked with two catch-all routes and server minification disabled; Bun has a smoke check only. Workers and Next.js Edge are not supported. See [Fetch API runtimes](/arc/backend/typescript/hosts/fetch-runtimes/). | `Source/Core/configuration/for_loadConfiguration`, `Source/Core/for_FetchArcApplication`, `yarn check:fetch`, `yarn check:fetch:next`, `yarn check:fetch:bun` | | Cratis composition | Experimental | `@cratis/cratis` composes Arc and Chronicle without installing an authentication handler; `CratisApplication.createBuilder` and `builder.addCratis`. It is not published to npm, and the composition itself has not been exercised against a live kernel. | `Source/Cratis` | | Cancellation on client disconnect | Supported for Express, Fastify, and Node | Hono passes the signal of the request it received. | `Source/Express/for_cratisArc`, `Source/Fastify/for_cratisArc`, `Source/Core/http/for_createArcNodeHandler` | | Unsupported methods | Supported | 405 with `Allow` for methods that reach Arc; the standalone host rejects TRACE and CONNECT. | `ContractTests/Http/conformance.test.mjs`, `Source/Core/http/for_runArc` | | [Standalone host and static files](/arc/backend/typescript/core/) | Bounded | HTTP or HTTPS listener or request handler, public files, SPA fallback, path base, cache validators, and graceful shutdown. No private file authorization, directory listing, multiple roots, or byte ranges. | `Source/Core/http/for_runArc`, `.../for_createArcNodeHandler`, `Source/Core/for_ArcApplication` | | [Tracing and metrics](/arc/backend/typescript/observability/) | Bounded | Versioned `Cratis.Arc` OpenTelemetry API spans and command/query duration histograms in seconds with canonical attributes (`cratis.arc.command.type`, `cratis.arc.query.name`); the operation-duration histogram remains emitted but is deprecated; the application supplies the SDK. Host-owned HTTP instrumentation produces a SERVER span above Arc’s command HTTP span on real Express, Fastify, and Hono Node servers; Express and Fastify instrumentation also produces framework spans in the ancestry. Foreign routes have no Arc spans. A host-owned pino recipe verifies callback correlation, redacted HTTP errors, and omission of a command payload from structured logs. | `Source/Core/for_ArcServer/when_tracing_operations`, `yarn check:observability-recipes` | * **Node configuration and Fetch dispatch.** `app.fetch(request)` answers 404 on non-Arc paths and `app.handle(request)` falls through. A neutral bundle allows only `node:async_hooks`, with no Node filesystem/crypto/stream imports, and passes in a restricted VM. Next.js 15.5.14 `next build && next start` on Node.js 26.8.1 passes commands, GET, validation, authorization, snapshot and streamed direct/hub SSE, direct client abort and subscription release, and tenant/correlation headers; Next.js itself returns 404 for unmatched routes. This is a local production-server check, not a deployment check. Server minification must be disabled to preserve model-bound class names. Next.js rejects HTTP `QUERY` with 400 before Arc sees it; use GET. Bun 1.3.10 `Bun.serve` passes the shared scenario including `QUERY`, as an optional smoke check, not a deployment guarantee. The older Deno 2.9.7 check passed the smaller pre-expansion scenario; the expanded scenario has not been rerun there. Arc targets Node-compatible servers for Chronicle gRPC, MongoDB TCP, and live queries; Cloudflare Workers and Next.js Edge are not supported, and these checks do not verify deployed production hosting or optional integrations. ## Deliberate differences * **Ambiguous command response alternatives.** Both runtimes serialize only the selected value; an arbitrary `Result` error DTO is a 200 success response, not a rejection. The TypeScript proxy generator rejects alternatives requiring different client decoders or cardinalities, while .NET 22.45.0 picks one response type and hydrates all alternatives through that decoder. Use one DTO with an application-owned status field for several client-visible business outcomes. * **Malformed command input and filters.** TypeScript runs authorization filters with raw input when binding fails, allowing a denial to take precedence over a 400 response. .NET 22.45.0 returns 400 before those filters for malformed command input. The paired fixture checks both execute and `/validate` modes; authorized malformed input returns `malformedRequest` on both with different message text. * **Global filter warnings and errors.** TypeScript applies severity filtering to each command and query fragment before short-circuiting; .NET short-circuits before filtering. Otherwise a filtered-out warning could skip later authorization filters. A thrown global command or query filter produces a 500 exception result, not .NET’s 400 `IValidationFailure` result; ordinary per-definition validation failures still produce 400. * **Severity 3 over HTTP.** On a command, `X-Allowed-Severity` accepts `0`, `1`, and `2`. A request that sends `3` is treated as `2` (Warning), so error-severity results still block with 400 and the handler does not run. Arc on .NET 22.45.0 accepts `3` and runs the command unless it declares `[BlockOnValidationSeverity]`. Queries ignore the header. A trusted caller of `executeCommand` can still pass `Severity.Error`. * **Anonymous caller on a protected operation.** With authentication handlers configured, Arc for TypeScript answers 401; Arc on .NET answers 403. * **Method authorization on commands.** TypeScript rejects `@allowAnonymous()`, `@authorize()`, or `@roles()` on `handle()`, `provide()`, or another command method at build; declare authorization on the class. The pinned .NET 22.45.0 command pipeline evaluates only the type and ignores authorization attributes on `Handle()`. Query methods on both runtimes replace, rather than combine with, their read-model class declaration; stacked attributes on one target are AND requirements, with OR roles within one attribute. * **Identity denial and display cookie.** `/.cratis/me` returns a JSON `{ error }` on 401/403 in TypeScript; the pinned .NET host sends an empty body. Both return the same successful identity fields and a base64 display cookie, but cookie attribute casing and base64 escaping are host-dependent. TypeScript never reads the unsigned cookie as a credential; .NET 22.45.0 reads it before checking the authenticated principal. Identity-details schemas describe the same required fields but use different schema generators. Both runtimes require authentication outside Development by default for discovery (`/.cratis/commands`, `/.cratis/queries`, `/.cratis/users`, `/.cratis/tenants`, and `/.cratis/identity-details/schema`), with an anonymous opt-out and optional roles. The paired Production fixtures assert anonymous 401 and authenticated 200 across Express, Fastify, and Hono for all five routes; denial bodies remain empty on .NET and JSON on TypeScript. TypeScript additionally protects `/openapi.json`; see [Discovery access](/arc/backend/typescript/introspection/#production-access). * **Policy context and authentication schemes.** .NET 22.45.0 evaluates named policies with scoped DI classes, a reflected target, and a command or query context; TypeScript also offers function policies, and its class policies receive an operation definition and an input and execution resource. Node scheme selection uses Arc handlers, not ASP.NET Core scheme composition, and scheme-protected observable queries are rejected at build. `jwtBearer()` is TypeScript-only. The paired 22.45.0 fixture checks named-policy allow and deny on commands and queries, but does not compare policy context objects or scheme selection. * **Query health.** Caller-scoped and opt-in, instead of .NET’s anonymous cross-caller view. * **Anonymous SSE hub ownership.** Like .NET, anonymous callers can open an SSE hub connection but have no identity that distinguishes them from other anonymous callers. TypeScript additionally binds anonymous controls to the opening peer address when the host supplies one; without an address, callers in the same tenant who know the random connection ID cannot be distinguished. Authenticated connections require the same authenticated identity, and anonymous callers cannot control them. * **Observable wait bounds.** `waitForFirstResultTimeout` is capped at 120 seconds, and an unrecognized boolean is rejected instead of ignored. * **Message texts.** Malformed requests say `Malformed request`, and redacted exceptions say `An unexpected error occurred`; Arc on .NET uses different texts. An unreadable `QUERY` body returns an exception envelope on both, with these different redacted texts. Exposing exception details reveals TypeScript’s reader error and stack. * **Preparation and binding.** A model-bound `provide()` passes one value as the first `handle()` argument; several values need `tuple(...)` and `provided(Type)` markers, where .NET infers assignable types. Without generated metadata, standard-mode queries need ordered descriptors. * **Compiler metadata.** Standard decorators can use generated metadata or explicit tokens; legacy decorators infer only class-valued dependencies from `design:paramtypes`. Generated metadata infers observable declarations from the return type; without it, use `{ observable: true }`. * **Enum fields.** Use `@field(String)` or `@field(Number)` with `@enumeration(Enum)`; numeric reverse mappings are ignored. * **Hosting defaults.** The Node host binds loopback by default. File discovery imports a dedicated artifacts folder, and moving a folder changes its derived route. * **Invalid GUID query arguments.** Both runtimes return 400 `malformedRequest`. .NET 22.45.0 identifies the argument and query in its message and reports the argument member; TypeScript returns `Malformed request` with no members. * **Structured QUERY argument names.** TypeScript rejects undeclared argument names with `malformedRequest` so typos do not silently change query results; .NET ignores them. Both ignore unknown members of the envelope, paging, and sorting objects, and a direction without a field. * **QUERY numeric tokens.** JavaScript JSON parsing treats `2.0` and `2e0` as integer 2 and nested `null` paging numbers as missing values. .NET’s int32 body reader rejects all three; unlike JavaScript, its JSON reader retains the original numeric token. A top-level `null` body or a `null` envelope member is absent in both implementations. * **Invalid QUERY sort fields.** TypeScript rejects names outside the declared field-name syntax with 400 `malformedRequest`; the pinned .NET array renderer fails with a redacted 500 for an invalid name such as `name!`. This is a fail-early input guard. * **Body size limit.** TypeScript’s `hosting.maxBodyBytes` rejects oversized QUERY bodies with 400 `malformedRequest`; the pinned .NET fixture has no corresponding configured limit and accepts an otherwise valid large body. * **Bounded provider reads.** MongoDB and Drizzle read at most their configured maximum page size; when an unpaged result exceeds it, they return a 400 paging-required `rule` on `Size`. A requested page larger than that maximum also returns a 400 rule. Arc on .NET returns all matching rows without a bound (`QueryableQueryRenderer.Execute`, `MongoCollectionExtensions.cs`). * **Sorting collation.** In-memory string sorting uses `localeCompare`, not .NET invariant-culture collation. ## How the integrations are checked The integration pages describe behavior. This section records the checks behind it. ### Chronicle checks The specs run with the published Chronicle TypeScript SDK, `@cratis/chronicle` 6.49.0, and `@cratis/fundamentals` 7.22.0; both load in native Node ESM with NodeNext resolution. The live suite was last verified with SDK 6.10.0; a 6.14.0 attempt could not start the local kernel because its MongoDB connection was refused. The ordinary `yarn test` specs use typed substitutes or the SDK’s in-memory `ReadModelScenario` and never start a kernel. An opt-in suite, `bash Source/Chronicle/run-integration.sh`, runs [`Source/Chronicle/Integration/live.test.mjs`](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Chronicle/Integration/live.test.mjs) against a real development kernel and checks: * returned-event batches, readback, and tenant isolation; * a reactor that returns an Arc command, executed in the triggering event’s tenant; * before-first concurrency rejection, and aggregate rehydration, commit, and concurrency rejection; * operation compensation after a concurrency rejection; * command-key read models for existing and missing keys; * a projected read-model query, through a read-model interceptor; raw MongoDB ciphertext for a PII-marked projected read model, and plaintext delivery through Chronicle HTTP snapshots, observable SSE, command injection, and an Arc query reading the materialized MongoDB collection directly; * all of it through real Express, Fastify, and Hono HTTP adapters. The suite needs Docker. Its image, `cratis/chronicle:latest-development`, is mutable, so pin a compatible image for reproducible deployment testing. Validator read models (`readModelForValidation`), subject resolution, and `@notAudited` are covered by substitute-based specs, not by the kernel suite. The [Library sample](/arc/backend/typescript/getting-started/library-sample/) has its own kernel run, `bash Samples/Library/run-integration.sh`. ### MongoDB checks `bash Source/MongoDB/run-integration.sh` starts a task-owned MongoDB 7 replica set in Docker and removes it afterward. The [integration spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/MongoDB/for_MongoCollection/when_observing_changes/with_a_replica_set.integration.ts) exercises initial snapshots, insertion, deletion, tenant isolation, dependency injection, and provider paging, and a [second spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/MongoDB/for_MongoCollection/when_serving_a_paged_query/with_each_http_adapter.integration.ts) serves sorted pages through Express, Fastify, and Hono. The script exits with 2 when Docker is not available, which means the check did not run. Unit specs cover the codec against .NET-shaped documents, naming policies, burst coalescing, the item cap, and stream failures. ### SQL checks `yarn vitest run --project @cratis/arc.drizzle` runs the SQLite specs on `sql.js`. The [adapter HTTP spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Drizzle/for_DrizzleReadModels/when_serving_a_sqlite_page/with_each_http_adapter.ts) serves a sorted page through Express, Fastify, and Hono and checks that an unknown sort field answers 400; the [tenant-isolation spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Drizzle/for_DrizzleReadModels/when_paging_across_tenants/with_sqlite.ts) checks `databaseFactory` routing. `bash Source/Drizzle/run-integration.sh` runs the [PostgreSQL spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Drizzle/for_DrizzleReadModels/when_reading/with_postgres.integration.ts) against PostgreSQL 16 and the [MySQL spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Drizzle/for_DrizzleReadModels/when_reading/with_mysql.integration.ts) against MySQL 8.4 in disposable Docker containers. The MySQL check covers databaseFactory tenant isolation, stable SQL counts and sorted pages (including ties), concept/GUID/JSON/date/time codecs, invalid sort field rejection, paging limits, and command read-model lookup by GUID key (including a missing key). The [PostgreSQL command spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Drizzle/for_DrizzleReadModelForCommandResolver/when_injecting/with_postgres.integration.ts) checks node-postgres command lookup with typed GUID and concept keys, tenant-scoped tables, and missing required and optional rows. `run-integration.sh` exits with 2 when Docker is unavailable. Neither suite verifies concurrent writes between count and page. ### Proxy generation checks The generated proxies compile against `@cratis/arc` and `@cratis/arc.react` 22.45.0 with `@cratis/fundamentals`, in strict `Bundler` mode with `skipLibCheck: false`. `yarn test:client-generation` builds the workspace, generates the Tasks proxies, compiles them, and runs commands, queries with arguments, paging, sorting, observable snapshots, and hub updates against live model-bound Express, Fastify, and Hono servers. Nullable command types and interface-only model mode have compile coverage only, not live-client equivalence. ## Shared Arc page examples The shared Arc pages show a TypeScript tab beside C#, Kotlin, and Java. Its snippets live in `Documentation/client-snippets/`, and `yarn docs:snippets` compiles each one against this repository’s packages with strict settings and standard decorators. Every shared snippet is a real TypeScript example. The in-memory `ChronicleCommandScenario` builds reducer-backed and supported flat projection-backed read models from seeded events and rejects projections outside that boundary with `UnsupportedProjectionOperation`; test those with `ChronicleKernelScenario`. The shared validator examples inject an application-owned repository. Arc for TypeScript validators do not take read models as constructor parameters; read one inside an async rule with `readModelForValidation`. A compiled snippet shows that the API exists with that shape; it is not a parity claim for the page around it. ## How parity is checked A conformance suite, `yarn test:conformance`, runs paired checks against a .NET host built on the published `Cratis.Arc` 22.45.0 package and Arc for TypeScript mounted in Express (and, for filter checks, Fastify and Hono). Global command and query filter HTTP requests are paired with .NET across Express, Fastify, and Hono; direct WebSocket filter admission and filter-stage traces are TypeScript-only checks because the .NET fixture does not expose a direct WebSocket upgrade or fixture stage counters. It covers command execution, validation-only requests, authorization before validation including validation-only denial, business-rule and malformed-input rejection, tuple response validation, GET and `QUERY` binding, paging, sorting, exception redaction, unsupported methods, correlation IDs, model-bound commands and validation, acronym naming, enum and named-float output, an overridden query path, and a conventional GUID query route with valid and invalid inputs. It also checks named-policy allow and deny, numeric concept GET arguments, validation severity, and observable snapshots with current (200) and pending (202) values. Invalid sorting, GET and structured paging boundaries, malformed `QUERY` bodies (redacted 400, `no-store`, and no handler execution), and int32 page-offset clamping are paired against the published fixture. It also pairs identity details and the display cookie (while explicitly rejecting .NET’s unsigned-cookie authentication), header/fixed/verified-claim/subdomain tenant routing, class and method authorization, malformed numeric/enum/concept inputs and execution counters, query validation and exceptions, correlation IDs on 400/403/500/202, and observable first emissions, waits, timeouts, and completion. The query reader’s exception envelope matches; its redacted text differs as noted above. Numeric concept GET arguments bind successfully on both runtimes, and both apply GET sorting and reject invalid sort directions. It pins the deliberate differences above and a host-specific difference that is not a choice of Arc for TypeScript: Express answers an unknown path with its own HTML 404 without an Arc correlation header. The suite needs the .NET 10 SDK and is not part of `yarn ci`. It is a bounded check of those routes, not a claim of full parity. # Diagnostics > Every place Arc for TypeScript reports a problem, from lint rules and proxy generation to build errors, validation reasons, HTTP status codes, and the endpoints and telemetry of a running server. Arc reports a problem at the earliest point it can see it. A binding mistake shows in the editor, a missing service stops `build()`, a bad request answers 400 with a reason, and a running server can describe what it serves. This page lists each of those surfaces and what it tells you. For step-by-step fixes of common problems, see [Troubleshooting](/arc/backend/typescript/troubleshooting/). ## In the editor: lint rules `@cratis/eslint-plugin-arc-core` checks model-bound artifacts before you build. Enabled rules report as errors. Both presets include bounded Chronicle checks for reactor and command appends and unmasked secret-looking fields when Chronicle is installed; `recommended-type-checked` also includes reactor replay decisions and the Guid response check. `query-argument-name` is opt-in. Rules that match a .NET analyzer keep its `ARC` or `ARCCHR` code. Setup is in [Code analysis](/arc/backend/typescript/code-analysis/). | Rule | Reports | | --- | --- | | [`arc0002`](/arc/backend/typescript/code-analysis/arc0002/) | A command-like class without `@command()` | | [`arc0003`](/arc/backend/typescript/code-analysis/arc0003/) | Command handling outside the command | | [`arc0004`](/arc/backend/typescript/code-analysis/arc0004/) | A command without a public instance `handle()` | | [`arc0005`](/arc/backend/typescript/code-analysis/arc0005/) | A value from `provide()` that `handle()` never consumes | | [`arc0010`](/arc/backend/typescript/code-analysis/arc0010/) | A synchronous command result wrapped in a promise | | [`arc0012`](/arc/backend/typescript/code-analysis/arc0012/) | A built-in error thrown from an artifact | | [`arc0014`](/arc/backend/typescript/code-analysis/arc0014/) | A query with type parameters | | [`arc0015`](/arc/backend/typescript/code-analysis/arc0015/) | An incoming parameter converted to a concept inside the query | | [`arc0019`](/arc/backend/typescript/code-analysis/arc0019/) | `@allowAnonymous()` combined with `@authorize()` or `@roles()` | | [`arcchr0003`](/arc/backend/typescript/code-analysis/arcchr0003/) | Direct reactor appends to its own default event log | | [`arcchr0006`](/arc/backend/typescript/code-analysis/arcchr0006/) | Returned Arc commands from live reactor handlers without a replay decision (type-checked preset) | | [`arcchr0007`](/arc/backend/typescript/code-analysis/arcchr0007/) | Direct default-log appends from a command’s `handle()` or `provide()`, including injected Chronicle services and `.transactional` appends | | [`arcchr0009`](/arc/backend/typescript/code-analysis/arcchr0009/) | Unmasked secret-looking command fields and constructor parameter properties when Chronicle resolves | | [`arcchr0010`](/arc/backend/typescript/code-analysis/arcchr0010/) | A keyless command returning a type-checked Fundamentals `Guid` value beside a direct decorated event (type-checked preset) | | [`missing-field`](/arc/backend/typescript/code-analysis/missing-field/) | A model-bound property without `@field` | | [`declared-field`](/arc/backend/typescript/code-analysis/declared-field/) | A decorated field that would not be emitted | | [`inject-binding`](/arc/backend/typescript/code-analysis/inject-binding/) | `@inject` tokens that do not match the handler’s parameters | | [`query-binding`](/arc/backend/typescript/code-analysis/query-binding/) | `@query` descriptors that do not match the method’s parameters | | [`query-argument-name`](/arc/backend/typescript/code-analysis/query-argument-name/) | A query wire name that differs from its parameter name (opt-in) | | [`misplaced-decorator`](/arc/backend/typescript/code-analysis/misplaced-decorator/) | An Arc decorator on an artifact that does not support it | | [`unexported-artifact`](/arc/backend/typescript/code-analysis/unexported-artifact/) | A decorated artifact that discovery cannot see because it is not exported | | [`validator-target`](/arc/backend/typescript/code-analysis/validator-target/) | A validator without `@validator(Target)` when no generated metadata names it | [Chronicle code analysis](/arc/backend/typescript/chronicle/code-analysis/) maps the remaining .NET diagnostics to TypeScript checks or review concerns. ## At generation: arc-proxygenerator * The CLI exits with code 1 and prints the error when generation fails, such as for a type it cannot map or a dynamic decorator option. See [Type mapping](/arc/backend/typescript/proxy-generation/type-mapping/). * A validator rule that needs server evaluation, such as `must` or `when`, stays on the server, and the generator prints a diagnostic naming it. See [Validation rules](/arc/backend/typescript/proxy-generation/validation/). * `--check-metadata` compares the generated metadata file with the source and fails when they differ, without generating anything; it prints `Generated artifact metadata is current` when they match. See [Generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/). ## At startup: build() and registration `builder.add(...)`, the `with...` integrations, and `builder.build()` refuse an application that cannot run correctly, instead of failing on the first request. Typical messages: | Message | Meaning | | --- | --- | | `Not an Arc artifact: ` | A class passed to `add()` has no Arc or integration decorator | | `Conflicting namespaces for ` | One class was registered under two different namespaces | | `Duplicate validator target: ` | Two validators target one class | | `Unbound handle parameters on .handle; use builder.useGeneratedMetadata(metadata) or @inject(...); default and rest parameters require explicit binding` | A command’s `handle()` has parameters without generated metadata or `@inject(...)` tokens; see [Troubleshooting](/arc/backend/typescript/troubleshooting/#build-fails-with-unbound-handle-parameters-or-missing-parameter-metadata) | | `Unbound provide parameters on .provide; use builder.useGeneratedMetadata(metadata) or @inject(...); default and rest parameters require explicit binding` | The same for a command’s `provide()` | | `Unbound parameters on .` | A query’s `@query(...)` descriptors do not cover every parameter of the method | | `Missing parameter metadata for .; use explicit tokens` | A bare `@query()` or an empty `@inject()` on a method with parameters, without generated metadata, in standard decorator mode | | `Unbound constructor parameters on ` | A service’s constructor parameters have no tokens | | `Service requires an implementation` | A `serviceToken` was registered without a class or factory | | `Missing service: ` | A declared dependency is not registered | | `Service dependency cycle: ` | Services depend on each other in a loop | | `Captive service dependency: ` | A singleton depends on a scoped or transient service | | `Expected one read-model resolver for , found ` | A `commandReadModel(Type)` has no owning integration, or two; see [When read model resolution fails](/arc/backend/typescript/chronicle/read-models/failures/) | | `Multiple identity details providers found` | More than one `@identityDetailsProvider()` | | `Import @cratis/arc. before calling with()` | An integration method was called without importing its package | | `Chronicle requires eventStore and exactly one of connectionString or client` | Incomplete `withChronicle` options or configuration | | `MongoDB requires exactly one of client, server, or serverResolver` | Incomplete `withMongoDB` options or configuration | | `Drizzle requires exactly one of database or databaseFactory` | Incomplete `withDrizzle` options | | `Invalid Cratis configuration: .` | An `appsettings.json` or `Cratis__...` value has the wrong type; the message names the key, never the value | The service messages are explained in [Dependency injection](/arc/backend/typescript/dependency-injection/). ## At request time: results and status codes A command or query result carries `validationResults`, and each result has a `reason`: | `reason` | Meaning | | --- | --- | | `rule` | A validator rule failed, or a required command read model is missing or has no key | | `malformedRequest` | The input does not match the declared fields | | `validatorFailed` | A validator threw; the error goes to `logger`, and the caller sees a generic message without the exception text | | `dependencyUnavailable` | A service a validator or handler needs could not be resolved | | `constraintViolation` | Chronicle rejected an append for a constraint; `reasonDetail` names it | | `concurrencyViolation` | Chronicle rejected an append because the stream moved; `state` holds the revisions | The HTTP status follows the [HTTP contract reference](/arc/backend/typescript/reference/http-contract/#status-codes): 400 for validation, 401 and 403 for authentication and authorization, 405 for an unsupported method, 408 and 503 for observable waits and limits, and 500 for exceptions. Outside development, a 500 carries `An unexpected error occurred` and no stack trace; the original error goes to the `logger` option. See [Configuration](/arc/backend/typescript/configuration/#errors-and-logging). ## On a running server | Surface | Shows | | --- | --- | | `GET /.cratis/commands`, `GET /.cratis/queries` | Every command and query with its route and input JSON Schema; see [Introspection](/arc/backend/typescript/introspection/) | | `GET /.cratis/identity-details/schema` | The identity details schema | | `GET /openapi.json` | The OpenAPI 3.1 document; see [OpenAPI](/arc/backend/typescript/open-api/) | | `GET /.cratis/queries/health` | The authenticated caller’s own observable hub connections, when `query.enableObservableHealth` is on; see [Query health](/arc/backend/typescript/queries/query-health/) | | OpenTelemetry | Spans such as `cratis.arc.command.execute` and the `cratis.arc.command.duration` / `cratis.arc.query.duration` histograms in seconds from the versioned `Cratis.Arc` scope; `cratis.arc.operation.duration` remains emitted but is deprecated; see [Observability](/arc/backend/typescript/observability/) | | `logger(error, correlationId)` | Every failure with its correlation ID, and unknown configuration keys | Every response carries its correlation ID, in `X-Correlation-ID` unless you renamed the header. Search your logs and traces by it. For the Chronicle side of a running system, such as failed observer partitions, use the Chronicle Workbench or the `cratis` CLI against the same event store and tenant namespace. ## Related * [Troubleshooting](/arc/backend/typescript/troubleshooting/) * [Capability reference](/arc/backend/typescript/reference/capabilities/) * [Glossary](/arc/backend/typescript/reference/glossary/) # Glossary > One line per Arc for TypeScript term, with the decorator or function behind it and the page that covers it in full. Terms shared by every Arc implementation are defined once in the [Arc glossary](/arc/glossary/). This page adds the TypeScript names for them, and the terms only Arc for TypeScript has. ## Commands * **Command**: a class decorated `@command()` whose instance `handle()` method carries out an intent. See [Model-bound commands](/arc/backend/typescript/commands/model-bound/). * **`handle()`**: the command’s method that decides and returns a response, events, operations, or nothing. * **`provide()`**: an optional command method that runs after validation, before `handle()`, to load data `handle()` needs; its return value is `handle()`’s first argument. * **Command key**: the value that identifies what a command acts on, from `@key()`, `getKey()`, or with Chronicle, `getEventSourceId()`. See [Command context](/arc/backend/typescript/commands/command-context/#give-a-command-a-key). * **Command context**: the command, its resolved key, request identity, and values, available to handlers through `commandContext()`. See [Command context](/arc/backend/typescript/commands/command-context/). * **Outcome**: a value created by `response(...)`, `rejected(...)`, or `denied(...)` that ends a command with a response, a 400, or a 403. See [Command outcomes](/arc/backend/typescript/commands/command-outcomes/). * **`tuple(...)`**: a return value that carries several values from `handle()`, at most one of which becomes the response. * **Response value handler**: a `CommandResponseValueHandler` that consumes a returned value on the server, such as a Chronicle event. See [Response value handlers](/arc/backend/typescript/commands/response-value-handlers/). * **Command operation**: a returned `CommandOperation` that Arc executes and, after a known failure, compensates. See [Command operations](/arc/backend/typescript/commands/operations/). ## Queries * **Read model**: a class decorated `@readModel()` whose static `@query(...)` methods are queries. See [Model-bound queries](/arc/backend/typescript/queries/model-bound/). * **Parameter descriptor**: `argument(name, Type)`, `service(token)`, or `queryOptions()` in `@query(...)`, telling Arc where each query parameter comes from. * **Observable query**: a query declared `@query({ observable: true }, ...)` that returns an RxJS observable or an async iterable, served as a snapshot, server-sent events, or WebSocket frames. See [Observable queries](/arc/backend/typescript/queries/observable-queries/). * **`queryPage(items, total, sorting?)`**: the result a query returns when its data source has already cut the page. See [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/). * **Read-model interceptor**: a `@readModelInterceptor()` class that transforms each read-model instance before it is encoded. See [Intercept read models](/arc/backend/typescript/queries/read-model-interception/). ## Types and validation * **Field**: a property declared with Fundamentals `@field(Type)`. Only declared fields are bound, validated, encoded, stored, and generated. See [Concepts](/arc/backend/typescript/concepts/). * **Concept**: a class extending Fundamentals `ConceptAs` with `static readonly valueType`, wrapping one primitive domain value. See [Concepts](/arc/backend/typescript/concepts/). * **Validator**: a class extending `CommandValidator`, `QueryValidator`, `ConceptValidator`, or `ModelValidator`, decorated `@validator(Target)`, with rules declared by `ruleFor`. See [Command validation](/arc/backend/typescript/commands/command-validation/). * **Allowed severity**: the highest validation severity that does not block a command; over HTTP it is capped at Warning. See [Validation severity filtering](/arc/backend/typescript/commands/validation-severity-filtering/). ## Services and hosting * **Application builder**: the builder from `ArcApplication.createBuilder()` where you add or discover artifacts, register services, and attach integrations before `build()`. See [Dependency injection](/arc/backend/typescript/dependency-injection/). * **Discovery**: `builder.discover(folderUrl)`, which imports a folder and registers every exported, decorated artifact. * **Service token**: a class, or a `serviceToken(name)`, that identifies a service in Arc’s container. * **Generated metadata**: the file `arc-proxygenerator` writes so that a bare `@query()` or `handle()` can infer its arguments and services. See [Generated artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/). * **`ArcServer`**: the built pipeline that executes commands and queries; hosts and `executeCommand`, `performQuery`, and `openObservableQuery` use it. See [Calling commands from code](/arc/backend/typescript/commands/calling-commands-from-code/). * **Host adapter**: `cratisArc` from `@cratis/arc.express`, `@cratis/arc.fastify`, or `@cratis/arc.hono`, which serves an Arc application inside that framework. See [Host adapters](/arc/backend/typescript/hosts/). * **Execution context**: the tenant, principal, correlation ID, allowed severity, and cancellation signal of one request or direct call. * **Authentication handler**: an ordered handler that turns a request into a principal, such as `jwtBearer()`. See [Authentication](/arc/backend/typescript/core/authentication/). * **Identity details provider**: an `@identityDetailsProvider()` class whose result `/.cratis/me` returns. See [Identity](/arc/backend/typescript/identity/). ## Client * **Proxy**: a generated TypeScript class for one command or query that the published `@cratis/arc` client executes. See [Proxy generation](/arc/backend/typescript/proxy-generation/). * **`arc-proxygenerator`**: the CLI that reads decorated TypeScript source and writes proxies and generated metadata. ## Chronicle * **Returned event**: an `@eventType()` instance returned from `handle()`, which the integration appends. See [Returning events](/arc/backend/typescript/chronicle/commands/). * **Batch**: the returned events of an outer command and its nested commands, appended together after the outer command succeeds. See [Transactional commands](/arc/backend/typescript/chronicle/commands/transactional-commands/). * **Concurrency scope**: the expected tail of an event source, set by `{ concurrency: true }`, `eventsWithConcurrencyScopes`, or an aggregate. See [Concurrency](/arc/backend/typescript/chronicle/commands/concurrency/). * **Subject**: the compliance identity recorded on an appended event, from `getSubject()`, `@subject()`, `@eventSubject`, or the event source ID. See [Subject](/arc/backend/typescript/chronicle/commands/subject/). * **Aggregate root**: a class extending `AggregateRoot`, bound with `commandAggregate(Type)`, rehydrated from the command key’s events. See [Aggregates](/arc/backend/typescript/chronicle/aggregates/). * **Command read model**: a read model loaded for the command’s key with `commandReadModel(Type)` or `readModelForValidation(Type)`. See [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/). * **Reactor command**: an Arc command returned from a Chronicle reactor and executed through the command pipeline. See [Returning commands from a reactor](/arc/backend/typescript/chronicle/reactors/command-side-effects/). ## Storage * **Collection token**: `mongoCollection(Type)`, the service token for the current tenant’s MongoDB collection. See [Get started with MongoDB](/arc/backend/typescript/mongodb/getting-started/). * **Naming policy**: the `MongoNamingPolicy` that decides stored property and collection names. See [Naming policies](/arc/backend/typescript/mongodb/naming-policies/). * **Read handle**: `drizzleReadModel(Type)`, a read-only SQL handle for queries; `drizzleDatabase()` is the writable counterpart for commands. See [Get started with SQL](/arc/backend/typescript/sql/getting-started/). * **Column codec**: a conversion such as `guidCodec` or `conceptCodec`, wrapped in `pgColumn`, `mysqlColumn`, or `sqliteColumn`. See [Column types](/arc/backend/typescript/sql/column-types/). # HTTP contract reference > Every route Arc for TypeScript serves, its methods and answers, the headers it reads and writes, and where its wire behavior differs from Arc on .NET. The language-neutral [Arc HTTP contract](/arc/http-contract/) is the specification: routes, methods, headers, result envelopes, status codes, identity, and validation values. This page lists what Arc for TypeScript serves against it. The status of each difference, and the evidence for it, is in the [capability reference](/arc/backend/typescript/reference/capabilities/#deliberate-differences). ## Routes | Path | Methods | Answer | | --- | --- | --- | | A command route, such as `/api/tasks/registration/register-task` | POST | Command result | | `/validate` | POST | Command result after authorization and validation; the handler never runs | | A query route, such as `/api/tasks/listing/all-tasks` | GET, and `QUERY` unless disabled | Query result; `QUERY` responses carry `Cache-Control: no-store` | | An observable query route | GET, `QUERY`, SSE with `Accept: text/event-stream`, direct WebSocket upgrade | Current snapshot, a stream of direct SSE results, or direct WebSocket `Data` frames | | `/.cratis/queries/ws` | WebSocket upgrade | Multiplexed WebSocket hub | | `/.cratis/queries/sse`, `/.cratis/queries/sse/subscribe`, `/.cratis/queries/sse/unsubscribe` | GET, and authenticated POST | Multiplexed SSE stream and its caller-bound controls | | `/.cratis/commands`, `/.cratis/queries` | GET | Command and query metadata with input JSON Schema | | `/.cratis/identity-details/schema` | GET | Identity details JSON Schema, or `{}` | | `/.cratis/me` | GET, when an identity details provider exists | 401 anonymous, 403 provider denied, 200 identity JSON with display cookie | | `/.cratis/users`, `/.cratis/tenants` | GET | `[]` by default; opt-in development discovery | | `/.cratis/queries/health` | GET and `QUERY`, when enabled | Caller-scoped hub health; requires authentication | | `/openapi.json` | GET | OpenAPI 3.1 document | The catalogs, identity schema, users, tenants, and `/openapi.json` are anonymous only in Development by default. Elsewhere they run the configured authentication handlers and require an authenticated principal (401 anonymous, 403 missing a configured role). Without authentication they are unmapped; explicitly requiring it without handlers fails startup. See [Discovery access](/arc/backend/typescript/introspection/#production-access) for environment detection, roles, and the anonymous opt-out. Methods that reach Arc but are not accepted answer 405 with an `Allow` header. Route shapes are explained in [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/). `introspection.enabled` defaults to `true`. Setting it to `false` (Node configuration: `Cratis:Arc:Introspection:Enabled`) leaves `/.cratis/commands`, `/.cratis/queries`, and `/openapi.json` unmapped in every environment, normally returning 404. Identity discovery and in-process `openApi()` / `exportClientManifest` are unchanged. See [Turn discovery off](/arc/backend/typescript/introspection/#turn-discovery-off), including the authentication-warning interaction and TypeScript’s extension of the switch to HTTP OpenAPI. ## Headers | Header | Direction | Meaning | | --- | --- | --- | | `X-Correlation-ID` (configurable) | Request and response | Reused when a valid non-zero UUID, otherwise replaced; always returned | | `X-Allowed-Severity` | Request | `0`, `1`, or `2` on commands; `3` is capped to `2`; ignored on queries | | `x-cratis-tenant-id` (configurable) | Request | The requested tenant without `tenancy.resolve` or another configured tenant source | | `Authorization` | Request | Read only by the authentication handlers you configure | | `Cache-Control: no-store` | Response | On `QUERY` responses, discovery responses, and `/.cratis/me` | | `Allow` | Response | On 405 answers | | `Retry-After: 1` | Response | On observable admission 503 answers | ## Status codes A result is 200 when successful, then 403 for authorization failures, 400 for validation and malformed input, 202 for a pending observable snapshot, and 500 for exceptions, in the contract’s order. 401 answers an anonymous caller on a protected operation when authentication handlers are configured, and any failed authentication. Observable routes also use 408 for a timed-out first-result wait and 503 when a limit is reached. ## Where the TypeScript server differs | Area | Arc on .NET | Arc for TypeScript | | --- | --- | --- | | `X-Allowed-Severity: 3` | Runs a command with only error results | Treated as Warning; errors block | | Anonymous caller on a protected operation | 403 | 401 when handlers are configured | | Malformed request message | Framework message | `Malformed request` | | Redacted exception message | Framework message | `An unexpected error occurred` | | Invalid GUID query argument | 400 `malformedRequest` with argument details | 400 `malformedRequest` with a generic message | | SSE hub | Anonymous controls allowed | Requires an authenticated principal | | Query health | Anonymous, cross-caller | Opt-in, caller-scoped | | Discovery denial body | Empty 401/403 on the ASP.NET fixture | JSON `{ error }` with the same status | | `waitForFirstResultTimeout` | Larger values accepted; unknown booleans ignored | At most 120 seconds; unknown booleans rejected | Numeric concept GET arguments bind successfully on both runtimes. Both apply GET sorting and reject invalid sort directions. The suite also pins a host-specific difference that is not a choice of Arc for TypeScript: | Request | Arc on .NET 22.45.0 | Arc for TypeScript | | --- | --- | --- | | An unknown path under Express | Empty 404 with a correlation header | Express’s own HTML 404, without an Arc correlation header | The paired `yarn test:conformance` suite pins these differences against a .NET host on `Cratis.Arc` 22.45.0; see [How parity is checked](/arc/backend/typescript/reference/capabilities/#how-parity-is-checked). ## Related * [Arc HTTP contract](/arc/http-contract/) * [Capability reference](/arc/backend/typescript/reference/capabilities/) # Packages > The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client. Every package in this repository is at version 0.58.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: * **Inside the clone.** Put your application in a folder under `Samples/`, which the root `workspaces` list includes, and reference the packages with the `workspace:^` protocol, as [`Samples/Tasks/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/package.json) does. `workspace:^` resolves only inside this repository’s Yarn workspace. * **In your own project.** Pack each package you need with `yarn workspace pack --out ` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers. [Create an application](/arc/backend/typescript/getting-started/create-an-application/) walks through both paths, from an empty folder to a running command and query. ## Server packages | Package | Folder | Exports | Peer dependencies | | --- | --- | --- | --- | | `@cratis/arc.core` | `Source/Core` | `ArcApplication`, the artifact, field, authorization, and service decorators, validators, `ArcServer`, `define*`, results, authentication, identity, tenancy, introspection, `exportClientManifest`, `runArc`, `createArcNodeHandler`; `@cratis/arc.core/hosting` for WebSocket hosting primitives; Node builder loads `appsettings.json` and `Cratis__...` environment keys | `@cratis/fundamentals` `^7.19.6`, `@opentelemetry/api` `^1.9.0`; depends on `zod` 4 | | `@cratis/arc.express` | `Source/Express` | `cratisArc` middleware with `.injectWebSocket` | `express` `^5.0.0` | | `@cratis/arc.fastify` | `Source/Fastify` | `cratisArc` plugin | `fastify` `^5.0.0` | | `@cratis/arc.hono` | `Source/Hono` | `cratisArc` middleware, `serveCratisArc` Node helper, `createHonoWebSockets` for shared Node helpers | `hono` `^4.0.0`; optional `@hono/node-server` `^1.19.11` | | `@cratis/arc.testing` | `Source/Testing` | `CommandScenario`, `QueryScenario`, `ObservableQueryScenario`, `ArcScenario`, `given`, `shouldHaveRuleFailure` | | | `@cratis/arc.mongodb` | `Source/MongoDB` | `withMongoDB`, `mongoCollection`, `MongoCollection`, naming policies, `MongoReadModels` | `@cratis/arc.core`, `@cratis/fundamentals`, `mongodb` `^6.21.0` | | `@cratis/arc.drizzle` | `Source/Drizzle` | `withDrizzle`, `drizzleReadModel`, `drizzleDatabase`, `DrizzleReadModels`, column codecs | `@cratis/arc.core`, `@cratis/fundamentals`, `drizzle-orm` `^0.45.0` | | `@cratis/arc.chronicle` | `Source/Chronicle` | Experimental: `withChronicle`, `commandAggregate`, `reactorCommandResultHandler`, `executeCommandsAsSystem`, `eventForEventSourceId`, `eventSourceIdResponse`, `eventsWithConcurrencyScopes`, routing decorators, `notAudited`, `ChronicleReadModels`; `@cratis/arc.chronicle/testing` for `ChronicleCommandScenario` and `ChronicleKernelScenario` | `@cratis/arc.core`, `@cratis/arc.testing`, `@cratis/chronicle` `^6.29.0` (peer-floor exports checked against 6.29.0), `@cratis/fundamentals`, `zod` | | `@cratis/cratis` | `Source/Cratis` | Experimental composition, the counterpart of the C# `Cratis` package: `CratisApplication.createBuilder`, `builder.addCratis`; re-exports Arc, Chronicle and testing (`./testing`); no implicit authentication handler | Arc core, Arc Chronicle, Arc testing, Chronicle SDK, Fundamentals, `zod` | `@cratis/cratis` is experimental, like the Chronicle integration it composes, and is not published to npm yet. Unlike C# `AddCratis`, the TS composition does not install Microsoft identity automatically: for protected routes, explicitly choose an authentication handler (such as `microsoftIdentityPlatform()`) or your own trusted host principal; public routes need neither. It composes the client, not the event-store engine. See [The Cratis package](/arc/backend/typescript/chronicle/cratis-package/). ## Tooling packages | Package | Folder | Exports | | --- | --- | --- | | `@cratis/arc.proxygenerator` | `Source/Tools/ProxyGenerator` | The `arc-proxygenerator` CLI, `analyzeSource`, `renderSource`, `generateFromSource`, `renderGeneratedMetadata`, and the manifest path’s `renderClientManifest` and `generateClient` | | `@cratis/eslint-plugin-arc-core` | `Source/CodeAnalysis` | ESLint 10 rules and the `recommended` and `recommended-type-checked` presets; peers `eslint` `^10.0.0` and `@typescript-eslint/parser` `^8.70.0` | ## Node.js Core, adapter, MongoDB, and Drizzle package manifests declare Node.js 22 or later. Building the workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. ## The client packages The workspace pins `@cratis/arc` and `@cratis/arc.react` to 22.45.0, `@cratis/components` to 4.23.0, and `@cratis/fundamentals` to 7.22.0 (peer range `^7.19.6`). Chronicle development and sample dependencies use 6.49.0; the Chronicle peer floor remains `^6.29.0`. `@cratis/arc`, `@cratis/arc.react`, and `@cratis/arc.react.mvvm` are Arc’s existing TypeScript **client** packages, built and published from the [Arc repository](https://github.com/Cratis/Arc). This repository does not replace, rename, or republish them. They are the compatibility target for this server’s wire behavior, and generated proxies import them in your frontend. The server packages never depend on them. ## Related * [Capability reference](/arc/backend/typescript/reference/capabilities/) * [Prepare and publish a TypeScript release](/arc/backend/typescript/contributing/releases/) # Wire format > Understand derived-type discriminators, acronym naming, enum values and named floating-point literals. A payload that looks right in one client can still fail to round-trip in another: a subclass arrives as its base type, an acronym changes case, or `NaN` breaks a JSON parser. This page lists the rules Arc for TypeScript follows when it reads and writes models, and where they match Arc on .NET. ## Derived types A model-bound `@field(Base)`, or an array of `Base`, can contain a registered Fundamentals derivative: * Decorate each concrete subclass with `@derivedType('identifier')` and load its module before building the server. * For a polymorphic base, Arc requires a known `_derivedTypeId` in the input and materializes the concrete subclass. An unknown or missing ID is rejected. * In a multi-level hierarchy, a decorated intermediate type accepts its own ID and the IDs of its registered descendants. * Output includes `_derivedTypeId` **after** the concrete fields, and only when the field’s declared type has derivatives. An untyped top-level response does not acquire a discriminator automatically. * Introspection and the OpenAPI input JSON Schema describe the registered variants with `oneOf`. ```json { "title": "hello", "text": "world", "_derivedTypeId": "text" } ``` The source proxy generator emits the same `@derivedType` declaration. On response deserialization, the published Fundamentals client uses `_derivedTypeId` to select a registered class for **declared model fields**. It does not currently select a derivative for a top-level command response declared as the base type. When the response itself is polymorphic, wrap it in a DTO with a decorated base-typed field. This is a client limitation, not a server-side discriminator fallback. ## Property names Like the .NET JSON policy, Arc lowercases the initial letter of an ordinary PascalCase field (`RecordedValue` becomes `recordedValue`) but keeps a leading acronym (`HTTPCount` stays `HTTPCount`). Plain objects, dictionary keys, and introspection and OpenAPI schema property names are never renamed. ## Enums and numbers * Numeric TypeScript enum members configured with `@enumeration` remain numeric on the wire, as .NET enums do. String enums remain strings. * Nonfinite `Number` values use the JSON strings `"NaN"`, `"Infinity"`, and `"-Infinity"`. Ordinary finite values remain JSON numbers. * Inputs for numeric fields are finite-only. The field option that accepts the three named literals on input is internal: `@cratis/arc.core` does not export it yet. ## Null values Null properties are omitted from ordinary model output. A derived model serialized through a polymorphic field keeps its null fields. An explicit nullable input is accepted. ## The low-level Zod path The low-level Zod path is not a CLR JSON converter. It keeps its declared schema and input binding rules. ## Culture and sorting Node has no CLR per-thread culture. Arc parses query-string numbers through explicit numeric conversion and serializes wire values without using the process locale. Array sorting currently uses JavaScript’s `localeCompare`, which is not equivalent to .NET invariant-culture collation. Sort in the provider when stable cross-platform order matters. ## Correlation header The optional `ArcOptions.correlationId.httpHeader` chooses the ingress **and response** header name; the default is `X-Correlation-ID`. Invalid or non-UUID IDs are replaced. The setting does not change W3C `traceparent` propagation or instrument foreign routes. ## Related * [HTTP contract](/arc/backend/typescript/reference/http-contract/) * [Configuration](/arc/backend/typescript/configuration/) * [Observe Arc requests](/arc/backend/typescript/observability/) # SQL with Drizzle > Serve model-bound queries from application-owned Drizzle databases on SQLite, PostgreSQL, and MySQL, with SQL-side paging, column codecs, and tenant routing. Your read models live in SQL tables. Every query needs the right database for the tenant, a page and a total count computed in SQL rather than in memory, sorting that a client cannot turn into SQL injection, and conversions for GUIDs, concepts, and dates. `@cratis/arc.drizzle` does that on top of [Drizzle](https://orm.drizzle.team), while your application keeps its schema, its migrations, and its connections. Source preview `@cratis/arc.drizzle` is not published to npm. SQLite runs in-process; PostgreSQL 16 and MySQL 8.4 are exercised against disposable live Docker databases. MySQL coverage includes tenant routing, codecs, paging, limits, sort rejection, and command read-model lookup. PostgreSQL command lookup is checked with node-postgres, including tenant routing, typed keys, and missing rows. This does not establish full parity with Arc on .NET. The [capability reference](/arc/backend/typescript/reference/capabilities/#persistence-and-chronicle) has the status and the checks behind it. ## What it provides | Capability | Page | | --- | --- | | Register a database and read models with `withDrizzle`, serve a query, load a model by a command key, write from a command, and keep queries on a read-only handle | [Get started](/arc/backend/typescript/sql/getting-started/) | | Store GUIDs, concepts, dates, times, durations, and JSON per dialect | [Column types](/arc/backend/typescript/sql/column-types/) | | Map model fields to columns, generate and apply migrations, and choose database credentials | [Map the schema and own migrations](/arc/backend/typescript/sql/schema-and-migrations/) | | Count, sort, and page in SQL | [Paging and sorting](/arc/backend/typescript/sql/paging/) | | Announce writes and observe tenant-scoped SQL results in process (Experimental) | [Observe tables](/arc/backend/typescript/sql/observing-tables/) | | Route each tenant to its own database | [Tenancy](/arc/backend/typescript/sql/tenancy/) | ## Why Drizzle Drizzle’s typed, SQL-first table declarations and query builders let Arc share one read path across PostgreSQL, MySQL, and SQLite, while your application keeps ownership of its SQL and its migrations. Kysely is a capable typed query builder but does not supply the table and column mapping used here. Prisma centers on its own schema, client generation, and migration workflow. TypeORM centers on entities, decorators, and a unit of work rather than an explicit read-only handle. These are trade-offs, not claims that one ORM replaces another. Drizzle 0.45 is before 1.0. The peer dependency `^0.45.0` accepts 0.45 releases, **not** 0.46 or 1.0; the integration has to be tested and updated before that range changes. ## What it does not do * **No schema management.** `withDrizzle` never creates tables, adds columns, or runs migrations. See [Own the schema](/arc/backend/typescript/sql/getting-started/#own-the-schema). * **Only announced in-process changes.** Opt-in `DrizzleObservation.InProcess` observes registered tables after explicit `notifyChanged` calls. Other processes and unannounced writes are invisible; host-owned outer transactions are not covered by the command completion boundary. Observed pages are eventually consistent, not atomic count-and-row snapshots. See [Observe tables](/arc/backend/typescript/sql/observing-tables/). Cross-process PostgreSQL `LISTEN`/`NOTIFY` observation is a [separate experimental mode](/arc/backend/typescript/sql/observing-postgresql/). * **No transactions or change tracking.** There is no unit of work shared with command execution. Use a Drizzle transaction in your command when several writes must succeed together. * **One registration per application.** A second `withDrizzle` fails at build with a duplicate service. Several tenants use one registration with `databaseFactory`. * **No EF-only features.** Arc on .NET’s Entity Framework integration also has SQL Server, spatial Point, LineString, and Polygon types, several DbContexts, and automatic concept conversion in `BaseDbContext`. None of those are part of this package. Start with [Get started](/arc/backend/typescript/sql/getting-started/). # SQL column types and conversions > Declare column codecs for concepts, GUIDs, dates, times, durations, and JSON per SQL dialect, and know where they differ from Arc on .NET. A task’s title is a `TaskTitle` concept in your code and plain text in the table. Drizzle columns know the driver’s types, not Fundamentals concepts, GUIDs, or `DateOnly`. The column codecs close that gap: declare the conversion once on the table column, and both writes and Arc reads get your types back. ## Declare a column with a codec ```typescript import { ConceptAs } from '@cratis/fundamentals'; import { pgTable } from 'drizzle-orm/pg-core'; import { conceptCodec, ConceptCodecKind, dateOnlyCodec, DrizzleDialect, guidCodec, pgColumn } from '@cratis/arc.drizzle'; export class TaskTitle extends ConceptAs { static readonly valueType = String; } export const tasks = pgTable('tasks', { id: pgColumn(guidCodec(DrizzleDialect.PostgreSQL))('id').primaryKey(), title: pgColumn(conceptCodec(TaskTitle, ConceptCodecKind.String, DrizzleDialect.PostgreSQL))('title').notNull(), due: pgColumn(dateOnlyCodec)('due') }); ``` `pgColumn(codec)`, `mysqlColumn(codec)`, and `sqliteColumn(codec)` wrap a codec in that dialect’s Drizzle `customType`. The codec declares the SQL type, converts values on writes, and converts driver values back on reads, including the reads `queryPage` makes. The matching Arc read model declares `@field(Guid) id`, `@field(TaskTitle) title`, and `@field(DateOnly) due`. The read-model codec also checks that every declared Arc field has a table column, and rebuilds GUIDs, concepts, and dates from plain driver columns when a column has no custom codec. Writes through a plain column still need the value in the driver’s type, so prefer a codec column wherever a model field is not a plain string, number, or boolean. ## Codecs and SQL types | Codec | PostgreSQL | MySQL | SQLite | | --- | --- | --- | --- | | `conceptCodec(ConceptType, ConceptCodecKind.String, dialect)` | `text` | `text` (or `varchar(n)` with a fourth length argument) | `text` | | `conceptCodec(ConceptType, ConceptCodecKind.Number, dialect)` | `double precision` | `double` | `real` | | `guidCodec(dialect)` and GUID concept | `uuid` | `char(36)` | `text` | | `dateOnlyCodec` | `date` | `date` | `text` | | `timeOnlyCodec` | `time` | `time` | `text` | | `timeSpanCodec` | `text` | `text` | `text` | | `jsonCodec(dialect, validate)` | `jsonb` | `json` | `text` | ## Rules and differences from .NET Pass the concrete Fundamentals `ConceptAs` subclass and its underlying kind to `conceptCodec`. Because generic types are erased at runtime, declare `static readonly valueType = String`, `Number`, or `Guid` on your concept class; registration rejects an absent or mismatched type marker. Numbers must be finite. For indexed or primary-key MySQL string concepts, pass a fourth argument such as `conceptCodec(TaskName, ConceptCodecKind.String, DrizzleDialect.MySQL, 120)` to select `varchar(120)` rather than unindexed `text`. GUIDs use canonical strings in MySQL and SQLite, not binary(16); PostgreSQL uses native UUID. Unlike the .NET SQLite `GuidColumn` migration helper (`BLOB`) paired with a string value converter, this schema and converter both use text. **Do not reuse a .NET SQLite BLOB schema without a data migration.** When using `mysql2` with `dateOnlyCodec`, create the pool with `dateStrings: true`. Without it, MySQL `DATE` values arrive as JavaScript `Date` objects, which `dateOnlyCodec` cannot parse; the codecs expect date/time values as strings. The MySQL 8.4 live check covers string concepts stored as `varchar(120)`, GUIDs in `char(36)`, JSON, dates, and times through a pool configured this way. Number and GUID concepts, `TimeSpan`, and MySQL `text` concept columns have codec unit coverage but are not exercised by that live check. `jsonCodec` requires a function that validates untrusted stored data; its decoder parses string values, then calls that function. It does not install EF’s reflection-based JSON converter set. `TimeSpan` is serialized as Fundamentals text rather than a native interval, and fractional precision follows Fundamentals’ `toString`/`parse`. Text ordering is lexicographic, not duration ordering for negative or multi-day spans: do not sort by this column unless you store a separate numeric duration for ordering. Schema changes and nullability remain your responsibility. See [Map the schema and own migrations](/arc/backend/typescript/sql/schema-and-migrations/). # Get started with SQL > Declare a Drizzle table and an Arc read model, serve a paged query with withDrizzle, write from a command, and keep queries on the read-only handle. This page serves a SQL table through an Arc query. It uses SQLite through `sql.js`, which runs in WebAssembly and needs no native build, so you can follow it on any machine. The code follows the package’s [SQLite fixture](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Drizzle/for_DrizzleReadModels/given/a_sqlite_database.ts). PostgreSQL and MySQL use their respective Drizzle drivers; the live integration runs PostgreSQL 16 and MySQL 8.4 with `mysql2`. For MySQL tables using `dateOnlyCodec`, create the `mysql2` pool with `dateStrings: true` (for example, `createPool({ uri, dateStrings: true })`). Without it, `DATE` values arrive as JavaScript `Date` objects, which `dateOnlyCodec` cannot parse. Install `drizzle-orm` 0.45 and a Drizzle driver, here `sql.js`, next to `@cratis/arc.core` and `@cratis/arc.drizzle`. The Arc packages are not published to npm: install tarballs packed from a clone, or work inside the clone’s workspace, as [Create an application](/arc/backend/typescript/getting-started/create-an-application/) shows. ## Declare the table and the model The Drizzle table describes the storage. The Arc read model describes what a query returns. Tasks.ts ```typescript import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { field, Guid } from '@cratis/fundamentals'; import { key } from '@cratis/arc.core'; import { DrizzleDialect, guidCodec, sqliteColumn } from '@cratis/arc.drizzle'; export const tasks = sqliteTable('tasks', { id: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('id').primaryKey(), title: text('title').notNull() }); export class TaskRecord { @field(Guid) @key() id!: Guid; @field(String) title!: string; } ``` Every `@field` on the model needs a column with the same property name, and the table needs a primary-key column; registration fails otherwise. `sqliteColumn(guidCodec(DrizzleDialect.SQLite))` stores the `Guid` as text and reads it back as a `Guid`. [Column types](/arc/backend/typescript/sql/column-types/) lists the other codecs. ## Serve a query TaskQueries.ts ```typescript import { query, queryOptions, readModel, service, type QueryOptions } from '@cratis/arc.core'; import { drizzleReadModel, type DrizzleReadModels } from '@cratis/arc.drizzle'; import { TaskRecord } from './Tasks.js'; @readModel() export class TaskQueries { @query(service(drizzleReadModel(TaskRecord)), queryOptions()) static page(tasks: DrizzleReadModels, options: QueryOptions) { return tasks.queryPage(undefined, options); } } ``` `drizzleReadModel(TaskRecord)` is a service token for a read-only handle on the current tenant’s database. `queryPage` pushes the count, sort, limit, and offset into SQL; see [Paging and sorting](/arc/backend/typescript/sql/paging/). ## Register the database main.ts ```typescript import initSqlJs from 'sql.js'; import { drizzle } from 'drizzle-orm/sql-js'; import { ArcApplication } from '@cratis/arc.core'; import { DrizzleDialect } from '@cratis/arc.drizzle'; import { TaskRecord, tasks } from './Tasks.js'; import { TaskQueries } from './TaskQueries.js'; const SQL = await initSqlJs(); const native = new SQL.Database(); native.run('create table tasks (id text primary key, title text not null)'); const database = drizzle(native); const builder = ArcApplication.createBuilder({ tenancy: { resolve: () => 'default' } }); builder.add(TaskQueries).withDrizzle({ dialect: DrizzleDialect.SQLite, database, readModels: [{ type: TaskRecord, table: tasks }] }); const app = await builder.build(); await app.run(); ``` Importing `@cratis/arc.drizzle` adds `withDrizzle` to the builder; the exported `withDrizzle(builder, options)` function is equivalent. A GET on the `page` query’s route with `pageSize=10&sortBy=title` answers with up to ten tasks sorted by title, and `paging.totalItems` counted in SQL. An unknown sort field answers 400. `tenancy.resolve` makes every request use the `default` tenant, the only tenant the single `database` option serves. Without a tenant, SQL access fails with `A tenant is required for Drizzle access`. For more than one tenant, see [Tenancy](/arc/backend/typescript/sql/tenancy/). The `create table` statement stands in for a migration so the example is self-contained. `withDrizzle` never creates or changes tables; see [Own the schema](#own-the-schema). ## Write from a command A command that writes takes the writable database handle: AddTask.ts ```typescript import type { SQLJsDatabase } from 'drizzle-orm/sql-js'; import { field, Guid } from '@cratis/fundamentals'; import { command, inject, key } from '@cratis/arc.core'; import { drizzleDatabase, type DrizzleHandle } from '@cratis/arc.drizzle'; import { tasks } from './Tasks.js'; @command() export class AddTask { @field(Guid) @key() id!: Guid; @field(String) title!: string; @inject(drizzleDatabase()) handle(database: DrizzleHandle): void { database.native.insert(tasks).values({ id: this.id, title: this.title }).run(); database.notifyChanged(tasks); } } ``` `drizzleDatabase()` resolves to a `DrizzleHandle` whose `native` property is the tenant’s Drizzle database, typed as you declare it. Register `AddTask` with `builder.add(...)` like the query. `notifyChanged(tasks)` validates the registered table; it publishes changes only when you opt in to [in-process observation](/arc/backend/typescript/sql/observing-tables/). Inside the command, publication follows execution (including a transaction awaited inside it), not a transaction owned by a host or outer runner. For those, call it after commit. ## Load a read model in a command A command can receive the row whose single primary key matches its `@key()` value. This one renames a task and answers with the title it had before: RenameTask.ts ```typescript import type { SQLJsDatabase } from 'drizzle-orm/sql-js'; import { eq } from 'drizzle-orm'; import { field, Guid } from '@cratis/fundamentals'; import { command, commandReadModel, inject, key } from '@cratis/arc.core'; import { drizzleDatabase, type DrizzleHandle } from '@cratis/arc.drizzle'; import { TaskRecord, tasks } from './Tasks.js'; @command() export class RenameTask { @field(Guid) @key() id!: Guid; @field(String) title!: string; @inject(commandReadModel(TaskRecord), drizzleDatabase()) handle(task: TaskRecord, database: DrizzleHandle): string { database.native.update(tasks).set({ title: this.title }).where(eq(tasks.id, this.id)).run(); return task.title; } } ``` Register `RenameTask` with `builder.add(...)` before `build()`, alongside the SQL model in `withDrizzle.readModels`. Drizzle supplies the command’s `TaskRecord` from the current tenant database, and a missing row rejects the command with a validation failure before `handle()` runs. The key is validated against its model field: Arc codec (custom) columns bind a typed `Guid` or concept for Drizzle to encode; plain text, integer, or UUID columns bind the underlying primitive (a GUID string or concept value). Malformed GUID and integer keys fail before SQL comparison. To record the rename as an event instead of writing the row, return an event from `handle()` and register [Chronicle](/arc/backend/typescript/chronicle/add-event-sourcing/), which appends it; see [Returning events](/arc/backend/typescript/chronicle/commands/). Drizzle never appends events. Do not register `TaskRecord` as a Chronicle read model as well: injecting it with `commandReadModel` fails at build because two resolvers claim it. Command read-model injection needs exactly one column with `.primaryKey()`, and that column must be declared as an Arc `@field` on the model. Without `@field` on an existing column-level key, the model can still serve queries, but a command injecting it fails at build with “Expected one read-model resolver for ModelName, found 0”. A table-level `primaryKey({ columns })` is not recognized; `withDrizzle` rejects such a model at registration. Several column-level primary keys can serve queries but cannot resolve a command’s single key. Command read models are tested against SQLite, live MySQL 8.4, and live PostgreSQL 16 using node-postgres. PostgreSQL checks typed GUID and concept keys, missing required and optional rows, and tenant isolation. ## Keep queries read-only Queries take `drizzleReadModel(Model)`, commands take `drizzleDatabase()`. The read handle, `DrizzleReadModels`, exposes: | Member | Returns | | --- | --- | | `queryPage(filter, options)` | One page with the total, counted and cut in SQL | | `find(filter, sorting?)` | Every match, or throws when there are more than `maxPageSize` | | `findOne(filter)` | The first match in primary-key order, or `undefined` | | `findById(key)` | The row matching the single primary key, or `null` | | `table` | The Drizzle table | | `observe(filter?)`, `observePage(filter, options)`, `observeById(key)` | Experimental opt-in in-process SQL observations; [limits](/arc/backend/typescript/sql/observing-tables/) | It has no write methods and does not expose the writable database. A filter is a Drizzle `SQL` expression such as `eq(tasks.title, 'a')`, built with bound parameters; never interpolate request input into SQL text. This is an API boundary, **not** a database permission. Any code can still inject the writable token, and JavaScript can reach past TypeScript visibility. When queries must not be able to write, give them a connection with read-only database credentials. None of these methods accept a cancellation signal, and Arc does not pass the request’s signal to the driver. Set timeouts in the driver or the database. ## Own the schema Your application owns the schema and its migrations; Arc has no migration engine. Use [drizzle-kit](https://orm.drizzle.team/docs/drizzle-kit-overview) to generate SQL migrations from the table declarations, review the generated SQL, and apply it in your deployment **before** Arc starts serving requests. * A new non-nullable column on a populated table needs a default or a staged backfill. * With a database per tenant, migrate **every** tenant’s database. A migration that succeeded on one tenant proves nothing about the others. * Do not run schema changes from `databaseFactory`; it runs on requests. There is no TypeScript counterpart of .NET’s `AddStringColumn` and `AddJsonColumn` EF migration helpers. Declare column types with the [column codecs](/arc/backend/typescript/sql/column-types/) and let drizzle-kit generate the SQL. [Map the schema and own migrations](/arc/backend/typescript/sql/schema-and-migrations/) shows the workflow for one or many tenant databases. ## Own the connection Arc wraps the database you register in a scoped handle for each request, and never closes it. Close the connection or pool yourself, after `await app.dispose()`. ## Related * [Column types](/arc/backend/typescript/sql/column-types/) * [Paging and sorting](/arc/backend/typescript/sql/paging/) * [Tenancy](/arc/backend/typescript/sql/tenancy/) # Observe PostgreSQL tables across processes > Install application-owned change triggers and opt into PostgreSQL LISTEN/NOTIFY for Drizzle read models. **Experimental.** This protocol and its versioned trigger names may change before it leaves experimental status. Use [in-process observation](/arc/backend/typescript/sql/observing-tables/) for SQLite, MySQL, or PostgreSQL when only local, explicitly announced changes are needed. PostgreSQL mode observes committed changes from other connections and processes; there is no fallback to in-process observation if the listener fails. ## Install triggers in an application migration Arc does not create or repair database objects at runtime. For every registered observed PostgreSQL table, generate a drizzle-kit **custom migration**, paste the SQL returned by `postgresqlChangeTrigger(table, { schema: 'app' })` into that migration, and deploy it with your application’s migrations before opening observations. For tables declared with `pgSchema('app').table(...)`, omit `schema`; a conflicting override is rejected. Schema-less tables require an explicit migration schema (even if it is `public`). Pass the base `PgTable`, not a Drizzle `alias(...)`; aliases are rejected even if an existing table has the alias name. Run the helper once per table. An existing trigger with the reserved name fails migration installation instead of being overwritten; replace an owned trigger deliberately during an upgrade. Ordinary reads and writes still work without the triggers, but starting an observation fails before its first model read. ```typescript import { postgresqlChangeTrigger } from '@cratis/arc.drizzle'; import { tasks } from './schema.js'; const migrationSql = postgresqlChangeTrigger(tasks, { schema: 'app' }); // Write migrationSql into an application-owned drizzle-kit custom migration. ``` The helper creates `arc_notify_changes_v1()` and `arc_changes_v1` in the table’s schema: an invoker-security, statement-level AFTER INSERT/UPDATE/DELETE/TRUNCATE trigger. It publishes only the canonical, SQL-quoted `schema.table` name to the fixed `arc_changes` channel, not row contents, keys, or tenant identifiers. The channel is database-wide and is **not** an authorization boundary. Duplicate notifications or extra snapshots are possible; NOTIFY delivers no durable history. The reader must see current committed data. Queries with long-lived transaction snapshots or lagging replicas are unsupported. ## Connect one dedicated listener per active tenant Install `pg` ^8 as an application dependency when using `nodePostgresListener`; `@cratis/arc.drizzle` does not load `pg` at runtime for other modes. The listener factory must return a **new, unconnected, dedicated** `pg.Client` (not a `Pool` client or the connection Drizzle uses). Arc connects and closes it. Use a direct PostgreSQL connection or session-pooled proxy, not PgBouncer transaction/statement pooling. The factory receives a cancellation signal for this tenant listener, not a request’s lifetime. ```typescript import { Client } from 'pg'; import { DrizzleDialect, DrizzleObservation, nodePostgresListener } from '@cratis/arc.drizzle'; builder.withDrizzle({ dialect: DrizzleDialect.PostgreSQL, databaseFactory: (tenant) => queryDatabaseFor(tenant), readModels: [{ type: TaskRecord, table: tasks }], observation: { mode: DrizzleObservation.PostgreSQLNotify, listener: async (tenant, { signal }) => { const configuration = await listenerConfigurationFor(tenant, signal); return nodePostgresListener(new Client(configuration)); } } }); ``` The factory and Drizzle query database must route each tenant to the **same primary database**. A schema-less Drizzle table resolves through the **reader’s** `search_path`, not the listener’s. Keep every connection in a reader pool on the same stable role and `search_path`; request-dependent `SET LOCAL` is unsupported. Separate schemas in one database route by canonical schema/table payload; tenants reading the same physical table receive the same invalidations. Table filters and RLS remain your responsibility. Temporary tables, views, partitioned tables, partitions, tables with inheritance parents or children, and aliased `PgTable` read-model registrations are unsupported. A statement trigger fires only on the table a write names: a write through a parent never reaches a child’s trigger, and a write to a child never reaches the trigger of a parent whose reads include the child’s rows. Register the physical base table instead; alias names are not physical trigger targets. Normal writes with `session_replication_role = replica` may skip the trigger and are unsupported. `observe()`, `observeById()`, and `observePage()` open a lease before reading rows. The first lease for a tenant opens LISTEN; every lease then resolves the table it reads, checks that the listener is connected to the same database, and checks the trigger in `pg_trigger` and its helper in `pg_proc`. Missing, disabled, or incompatible triggers fail that observation; Arc does not attempt DDL or silently degrade. A plain HTTP GET of an observable query calls `current()` and likewise incurs a dedicated per-tenant listener and catalog reads until its scope closes. One tenant with several active subscriptions shares a connection; closing the final lease ends it. Budget one listener backend per active tenant and monitor `pg_stat_activity`. `notifyChanged()` still validates its registered target but publishes **nothing locally** in PostgreSQL mode: the database trigger is the sole invalidation source. On listener `error`/`end` or heartbeat transport failure, Arc pauses reads, retains each subscriber’s last snapshot, and requests a fresh dedicated connection. The internal reconnect delays are 500 ms, 1, 2, 4, and 8 seconds; each connection, listener and catalog operation has a 5-second deadline. After LISTEN is acknowledged, Arc re-resolves each live reader mapping and validates their triggers, then forces a fresh read of **every live subscription, including an unused `current()` prime**. A changed mapping or incompatible trigger fails the affected observation rather than silently switching tables. New subscriptions wait for recovery before their first model read. A successful heartbeat resets the retry budget; if the bounded attempts fail, all remaining tenant subscriptions error, and a subsequent subscription can start a new listener. Arc never falls back to in-process notifications. Trigger validation occurs on acquisition and reconnect, **not** during heartbeat checks; coordinate migrations with observer restart. Committed changes invalidate matching observations, and recovery catches up to the eventual committed state. PostgreSQL NOTIFY is not a durable event log: intermediate states during a disconnect are not replayed, and duplicate notifications can cause extra snapshots. Pages are not atomic count-and-page snapshots. The installed `@cratis/arc` frontend’s direct SSE `EventSource` retries a closed stream automatically; its SSE hub also reconnects and re-subscribes active queries. Custom SSE/HTTP consumers must reconnect after a terminal recovery error; an error delivered as a result frame may require application-level retry. For half-open connections Arc sends `SELECT 1` every 30 seconds. Watch `pg_notification_queue_usage()` as well: a stalled listener in a long-running transaction can fill the notification queue and make writers fail. Arc’s listener only executes autocommit statements, never `BEGIN`. # Observe SQL tables in process > Announce committed writes to tenant-scoped Drizzle read models and stream small SQL results. **Experimental, in-process only.** For committed changes from other processes on PostgreSQL, see [PostgreSQL LISTEN/NOTIFY observation](/arc/backend/typescript/sql/observing-postgresql/). This mode sees **only writes announced with `notifyChanged` in this application process**. Other processes, database clients, triggers, and unannounced writes are invisible. Host-owned transactions around Arc commands and transactions owned by an outer command runner are **not** covered by command completion: announce those writes **after commit**. Read from a connection that sees freshly committed data, not a long-lived repeatable-read snapshot or a lagging replica. Pages are **eventually consistent**, not atomic snapshots of count and rows. Install `rxjs` alongside `@cratis/arc.drizzle`: it is a required peer dependency, loaded even when observation is disabled. Enable `DrizzleObservation.InProcess` explicitly. Without it, starting an observation fails; ordinary read APIs and validated `notifyChanged` calls still work. ## A task slice This single file keeps the command, read model, query, table, and setup together. It uses SQLite through `sql.js`; your application owns the database and migrations. The command writes and announces the *same registered table object*. Tasks.ts ```typescript import initSqlJs from 'sql.js'; import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js'; import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { field, Guid } from '@cratis/fundamentals'; import { ArcApplication, command, inject, key, query, readModel, service } from '@cratis/arc.core'; import { DrizzleDialect, DrizzleObservation, drizzleDatabase, drizzleReadModel, guidCodec, sqliteColumn, type DrizzleHandle, type DrizzleReadModels } from '@cratis/arc.drizzle'; export const tasks = sqliteTable('tasks', { id: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('id').primaryKey(), title: text('title').notNull() }); export class TaskRecord { @field(Guid) @key() id!: Guid; @field(String) title!: string; } @command() export class AddTask { @field(Guid) @key() id!: Guid; @field(String) title!: string; @inject(drizzleDatabase()) handle(database: DrizzleHandle): void { database.native.insert(tasks).values({ id: this.id, title: this.title }).run(); database.notifyChanged(tasks); } } @readModel() export class TaskListing { @query({ observable: true }, service(drizzleReadModel(TaskRecord))) static all(records: DrizzleReadModels) { return records.observe(); } } const Sql = await initSqlJs(); const native = new Sql.Database(); native.run('create table tasks (id text primary key, title text not null)'); const database = drizzle(native); const builder = ArcApplication.createBuilder({ tenancy: { resolve: () => 'default' } }); builder.add(AddTask, TaskListing).withDrizzle({ dialect: DrizzleDialect.SQLite, database, readModels: [{ type: TaskRecord, table: tasks }], observation: DrizzleObservation.InProcess }); const app = await builder.build(); await app.run(); ``` Once running, request a snapshot or open a stream at the query route: ```bash curl 'http://127.0.0.1:3000/api/task-listing/all?page=0&pageSize=10' curl -N -H 'Accept: text/event-stream' \ 'http://127.0.0.1:3000/api/task-listing/all?page=0&pageSize=10' ``` Check the route generated by your application; namespaces or route configuration can change the URL. Plain GET returns 200 from `current()`. Arc pages the emitted array in memory for each request; SSE starts with the same page and emits again after an announced change. Unsubscribing or closing the request releases the listener. If you call `current()` in a long-lived background scope without subscribing, dispose the scope or call `close()` on the observable to release its listener. ## Timing, tenants, and limits `notifyChanged(table)` accepts only the registered table object; `notifyChanged(TaskRecord)` resolves to that table. A different table object with the same name is **not** registered and throws. The handle takes its tenant from the Arc execution scope; tenants cannot announce each other’s changes, even if `databaseFactory` points both at one physical database. The tenant key is lowercased just as it is for the factory. Inside an Arc command, announcements from nested commands for the same tenant are combined and published once **when that tenant’s outer command execution finishes**, whether it succeeds or fails. An outer command runner means one registered *earlier* than `withDrizzle`, including `options.commandExecutionRunner`; it may own a transaction that outlives the Drizzle runner. That follows any transaction awaited *inside* that command, but does not imply a transaction owned outside Arc has committed. Outside a command (including background jobs), `notifyChanged` publishes immediately: call it only after commit. A failed command can leave a nontransactional write behind, so failure also flushes announced changes. `observePage(filter, options)` is available only through low-level `defineObservableQuery`, without generated artifact metadata or client proxies; model-bound observable queries must use `observe()` because generated metadata rejects observable pages. See [observable queries](/arc/backend/typescript/queries/observable-queries/#page-and-sort-a-live-list). It counts, sorts, limits, and offsets in SQL for each emission. Count and row selection use separate statements: their lengths are checked, retried up to three times on disagreement, then the stream fails. Equal lengths do **not** prove an atomic snapshot. An announced write racing a read triggers another read, so pages converge on committed data. For small complete collections, `observe(filter?)` reads at most `maxPageSize + 1` rows in one statement and fails with `QueryPagingRequired` on overflow; it never emits a truncated list. `observeById(key)` emits `null` for a missing or deleted row and can emit that row again if recreated. A listener is registered before the initial read. Bursts coalesce to one trailing read while a read is in flight; there is no polling or time-based debounce. Read failures and paging failures end the subscription; reconnect by subscribing again to get a fresh snapshot. Core’s outbound queue limit (256 pending emissions by default) also fails a slow subscription explicitly. There is no cross-process delivery in this mode; MongoDB [change streams](/arc/backend/typescript/mongodb/observing-collections/) have a different change source. # Page and sort SQL read models > Push count, sort, limit, and offset into SQL with DrizzleReadModels.queryPage, filter with a typed predicate, and know the sort-field, size, and consistency rules. A task table grows to a million rows, and the client shows ten at a time. Loading every row to cut a page in memory stops working long before that. `DrizzleReadModels.queryPage` sends the count, the sort, and the page to the database and returns an Arc page with the real total. ## Page a query ```typescript import { eq } from 'drizzle-orm'; import { query, queryOptions, readModel, service, type QueryOptions } from '@cratis/arc.core'; import { drizzleReadModel, type DrizzleReadModels } from '@cratis/arc.drizzle'; import { TaskRecord, tasks } from './Tasks.js'; @readModel() export class TaskQueries { @query(service(drizzleReadModel(TaskRecord)), queryOptions()) static page(items: DrizzleReadModels, options: QueryOptions) { return items.queryPage(undefined, options); } @query(service(drizzleReadModel(TaskRecord)), queryOptions()) static open(items: DrizzleReadModels, options: QueryOptions) { return items.queryPage(eq(tasks.title, 'open'), options); } } ``` `TaskRecord` and `tasks` are the model and table from [Get started](/arc/backend/typescript/sql/getting-started/). A GET with `page=0&pageSize=10&sortBy=title` runs a `count(*)` and a `select ... order by ... limit 10 offset 0` in the tenant’s database, and answers with ten tasks and `paging.totalItems` from the count. The second query filters with a typed Drizzle predicate first; a filter is optional, and `undefined` means every row. Build filters from trusted values with Drizzle’s operators, which bind parameters. Never interpolate request text into SQL. ## Rules | Rule | Detail | | --- | --- | | Paging absent | `queryPage` reads the first `maxPageSize` rows. Arc returns an unpaged response only when the count fits; otherwise it rejects the incomplete list with 400. | | Page size | At least 1, at most `maxPageSize`: 100 by default, configurable up to 10,000 with `withDrizzle({ maxPageSize })` | | Page index | A nonnegative safe integer | | Sort field | Must be a declared `@field` on the model **and** a column of the table. An unknown field, or a table column the model does not declare, answers 400 before any SQL runs | | Sort direction | `asc` or `desc` | | Tie-breaker | The primary key, ascending, so pages stay stable when sort values repeat | | Selected columns | Only the model’s declared fields and the primary key; other columns are never read | The client’s sort field is matched against the declared fields and never becomes SQL text. PostgreSQL 16 and MySQL 8.4 live checks exercise count, sorted pages, and decoded columns; MySQL also checks tie-breaking, invalid sort rejection, and `QueryPagingRequired` limits. A table needs at least one column marked `.primaryKey()`. A composite key declared only through Drizzle’s table extras does not mark individual columns, so the adapter cannot use it as a tie-breaker, and registration fails. ## Unpaged reads `find(filter, sorting?)` returns every match only when there are at most `maxPageSize` of them. Above that limit it rejects with a `QueryPagingRequired` error saying to use `queryPage`, rather than silently truncating. Use `queryPage` for paged endpoints: `find` does not accept paging options, even if the client sends `pageSize`. `findOne(filter)` returns the first match in primary-key order, or `undefined`. ## Consistency The count and the page are separate statements. A write between them can change which rows are counted or returned; the adapter does not promise snapshot isolation. When the page no longer agrees with the count, Arc may reject the result as malformed instead of returning an inconsistent page. Offset paging gets slower for deep pages, so index the filtered and sorted columns and keep `maxPageSize` sensible. ## Related * [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/) for the request parameters * [Get started with SQL](/arc/backend/typescript/sql/getting-started/) # Map the schema and own migrations > Map Arc read-model fields to Drizzle table columns and codecs, generate and apply migrations with drizzle-kit in your host, order schema and code changes safely, and choose database credentials for reads, writes, and migrations. Your `TaskRecord` gains a due date. Three things have to change together: the Drizzle table, the Arc read model, and the database itself. Arc checks the first two against each other when the application starts, and never touches the third. This page shows how the mapping works, how to change the database with drizzle-kit, and which credentials each part needs. ## How fields map to columns Arc matches each `@field` on the read model to the table column with the **same property key** in the Drizzle table object. The SQL column name is yours to choose: Features/Tasks/Tasks.ts ```typescript import { pgTable, text } from 'drizzle-orm/pg-core'; import { field, DateOnly, Guid } from '@cratis/fundamentals'; import { key } from '@cratis/arc.core'; import { dateOnlyCodec, DrizzleDialect, guidCodec, pgColumn } from '@cratis/arc.drizzle'; export const tasks = pgTable('tasks', { id: pgColumn(guidCodec(DrizzleDialect.PostgreSQL))('id').primaryKey(), title: text('title').notNull(), dueOn: pgColumn(dateOnlyCodec)('due_on'), createdBy: text('created_by') }); export class TaskRecord { @field(Guid) @key() id!: Guid; @field(String) title!: string; @field(DateOnly) dueOn!: DateOnly | null; } ``` * `dueOn` maps to the `due_on` column, because the table object’s key is `dueOn`. * `createdBy` is in the table but not on the model. Arc never selects it, so a query never returns it. * Arc always selects primary-key columns, and uses them to order `findOne` and to resolve a command’s read model by key. When you register `{ type: TaskRecord, table: tasks }` in `withDrizzle`, Arc checks that the model has `@field` metadata and that every field has a column with its key. A missing column fails with `Drizzle model TaskRecord has no column for field: dueOn`, before the application serves a request. Arc checks the table declaration, not the database, so a column declared in TypeScript but missing in the database fails when a query runs. ## Columns and codecs A codec column converts between your type and the driver’s value on every read and write. A plain Drizzle column passes the driver value through, and Arc converts it to the field’s type on reads only: | Column | Reads | Writes | | --- | --- | --- | | `pgColumn(codec)`, `mysqlColumn(codec)`, `sqliteColumn(codec)` | The codec’s `fromDriver`, typed as your value | The codec’s `toDriver`, from your value | | A plain `text`, `integer`, `uuid`, … column | Arc rebuilds `Guid`, concepts, `DateOnly`, `TimeOnly`, and `TimeSpan` from the driver value | You pass the driver’s type, such as a GUID string | Prefer a codec wherever a field is not a plain string, number, or boolean. [Column types and conversions](/arc/backend/typescript/sql/column-types/) lists the built-in codecs and their SQL types per dialect. A codec is an object implementing `ColumnCodec` from `@cratis/arc.drizzle`: | Member | Meaning | | --- | --- | | `sqlType` | The SQL type written into the column declaration, such as `uuid` or `text` | | `toDriver(value)` | Converts your value to what the driver sends | | `fromDriver(value)` | Converts what the driver returns to your value | You can implement it for a type the built-in codecs do not cover and pass it to `pgColumn`, `mysqlColumn`, or `sqliteColumn`. On SQLite, `sqliteColumn` declares `date` and `time` codecs as `text`. ## Generate migrations with drizzle-kit Your host owns the schema. `withDrizzle` never creates tables, adds columns, or runs migrations, and there is no migration step in Arc’s startup. Use [drizzle-kit](https://orm.drizzle.team/docs/drizzle-kit-overview), which reads the same table declarations, including the SQL types your codecs declare: ```bash npx drizzle-kit generate --dialect postgresql --schema ./Features/Tasks/Tasks.ts --out ./drizzle ``` Review the generated SQL before you commit it. drizzle-kit cannot know that a new `NOT NULL` column on a populated table needs a default or a backfill, or that a rename is not a drop and an add. ## Apply migrations before serving Apply the migrations in your deployment, before the new version of the application starts. With a database per tenant, apply them to **every** tenant’s database. Drizzle’s `migrate()` function does this from a script you own: scripts/migrate.ts ```typescript import { drizzle } from 'drizzle-orm/node-postgres'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; import { Pool } from 'pg'; const tenants = ['acme', 'globex']; for (const tenant of tenants) { const pool = new Pool({ connectionString: `postgres://migrator@localhost/tasks_${tenant}` }); try { await migrate(drizzle(pool), { migrationsFolder: './drizzle' }); } finally { await pool.end(); } } ``` The script records applied migrations in each database, so running it again applies only new ones. Stop on the first failure and fix it: a migration that succeeded for Acme proves nothing about Globex. Do not run migrations from `databaseFactory`. It runs on requests, concurrently, and under the application’s credentials. ## Order schema and code changes Because Arc selects only the fields a model declares, plus the primary-key columns, you can change the database and the code in separate deployments: | Change | Order | | --- | --- | | Add a field | Migrate the column first, then deploy the code that declares it. Code that declares a column the database lacks fails its queries. | | Remove a field | Deploy the code without the field first, then drop the column. | | Rename a column or a field | Add the new column and backfill it, deploy the code that uses it, then drop the old column. Renaming only the SQL name keeps the model field, because the field follows the property key, but running code still selects the old name. | With several instances, old and new code run against the same schema during a rollout, so keep each step compatible with the version before it. ## Database credentials Arc gives queries a read-only handle, `DrizzleReadModels`, and commands the writable `drizzleDatabase()`. That separation is an API boundary for your code, not a permission: both resolve from the same database connection for the tenant, and any code can inject the writable token. Enforce the boundary in the database: | Role | Needs | Used by | | --- | --- | --- | | Migration | Create and alter tables | Your migration script, in deployment | | Application | Read, and write the tables your commands change | The pools you return from `database` or `databaseFactory` | | Read-only application | Read only | The pools of a service whose commands do not write these tables | Never give the application’s credentials the right to change the schema. When a service only serves queries from these tables, register a pool with read-only credentials; a command that tries to write through it then fails in the database, whatever the code does. ## Related * [Column types and conversions](/arc/backend/typescript/sql/column-types/) * [Get started with SQL](/arc/backend/typescript/sql/getting-started/) * [Route SQL by tenant](/arc/backend/typescript/sql/tenancy/) # Route SQL by tenant > Give each tenant its own SQL database with databaseFactory, keep single-tenant applications on the default tenant, and own the pools, credentials, and isolation checks. Two customers share your service, and neither may ever see the other’s tasks. The safest line between them is a database each. With `databaseFactory`, every request’s tenant selects the database its queries and commands use, and no query can ask for another tenant’s data. ## A database per tenant ```typescript import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import { DrizzleDialect } from '@cratis/arc.drizzle'; import { TaskRecord, tasks } from './Tasks.js'; const databases = new Map(); const allowedTenants = new Set(['acme', 'globex']); builder.withDrizzle({ dialect: DrizzleDialect.PostgreSQL, databaseFactory: tenant => { if (!allowedTenants.has(tenant)) throw new Error(`Unknown tenant ${tenant}`); let database = databases.get(tenant); if (!database) { database = drizzle(new Pool({ connectionString: `postgres://localhost/tasks_${tenant}` })); databases.set(tenant, database); } return database; }, readModels: [{ type: TaskRecord, table: tasks }] }); ``` This excerpt assumes an Arc `builder` and a PostgreSQL `tasks` table declared with `pgTable`, in the shape [Get started](/arc/backend/typescript/sql/getting-started/) shows for SQLite. Arc calls `databaseFactory` once per execution scope, after it has resolved the tenant, with the tenant ID in lowercase. A missing tenant, or a factory that returns nothing, fails the request. ## What you own Arc selects; your factory decides. The application owns: * **Creation and caching.** Reuse one pool per tenant, as above. A factory that creates a pool for any tenant ID a request names lets a caller exhaust your connections. * **The mapping.** Arc does not check that the database you return belongs to the tenant, and does not add a tenant predicate to any query. * **Credentials.** Give each tenant’s pool credentials that reach only that tenant’s database. * **Shutdown.** Arc never closes a database the factory returns. Close your pools after `await app.dispose()`. * **Migrations.** Migrate every tenant’s database before serving it; see [Own the schema](/arc/backend/typescript/sql/getting-started/#own-the-schema). Choosing the tenant’s database does not prove the caller belongs to that tenant. Configure a membership check or derive the tenant from the principal; see [Tenancy](/arc/backend/typescript/tenancy/). ## A schema per tenant For one database with a schema per tenant, return a pool or connection that is already confined to that tenant’s schema, such as one with a fixed search path. Never change the search path of a shared pooled connection per request unless a transaction guarantees it is reset. ## Single-tenant applications The `database` option is the short path for one database. It serves only the `default` tenant, and a request for any other tenant fails closed with `Drizzle database is only available for the default tenant`. Resolve every request to that tenant: ```typescript const builder = ArcApplication.createBuilder({ tenancy: { resolve: () => 'default' } }); ``` Set exactly one of `database` and `databaseFactory`; both, or neither, fails `withDrizzle`. ## Verify the boundary Test cross-tenant reads and writes with your real pool configuration and authorization: read as one tenant, write as another, and check that nothing crosses. The package’s [SQLite tenant-isolation spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Drizzle/for_DrizzleReadModels/when_paging_across_tenants/with_sqlite.ts) shows the shape of such a check. The [MySQL 8.4 integration spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Source/Drizzle/for_DrizzleReadModels/when_reading/with_mysql.integration.ts) uses two databases on one MySQL server and verifies that `databaseFactory` routes reads to the current tenant; it does not test authorization or cross-tenant writes. .NET EF pooled contexts do not infer a database per tenant either. This integration makes the choice explicit, like the MongoDB integration’s per-tenant resolvers, rather than reusing one pool across tenants. ## Related * [Tenancy](/arc/backend/typescript/tenancy/) * [Get started with SQL](/arc/backend/typescript/sql/getting-started/) # Tenancy > Serve several customers from one Arc application, choose how each request selects its tenant, prove the caller belongs to it, and keep tenant selection, membership, and storage isolation apart. Acme and Globex both use your task application, on the same deployment. A request from an Acme user must only ever read and change Acme’s tasks. One missed check, and a Globex user sees a competitor’s data. Arc resolves a tenant for every request, before your code runs, and carries it through commands, queries, services, and storage integrations. You decide how the tenant is selected and how membership is proven. Arc makes sure the answer is the same everywhere in the request. ## Three decisions, kept apart ``` Request Selection which tenant does the request name? Membership may this caller use it? Storage isolation where do reads and writes go? ``` | Decision | Who makes it | | --- | --- | | **Selection** | Arc, from the source you configure: a header, a claim, a subdomain, or your own resolver | | **Membership** | Arc with `tenancy.membershipClaim`, your `tenancy.resolve`, or your authorization rules | | **Storage isolation** | The storage integration: a MongoDB database, a Drizzle connection, or a Chronicle namespace per tenant | Each decision depends on the one before it, and none replaces another. A separate database per tenant does not stop a Globex user from naming Acme in a header. [Storage isolation](/arc/backend/typescript/tenancy/isolation/) covers the third decision. ## Three ways to select a tenant | Configuration | Behavior | | --- | --- | | Nothing | Arc reads the `x-cratis-tenant-id` header unchanged. Missing header means `tenantId` is `undefined` | | `tenancy: { resolve(request, principal) }` | Your resolver alone decides, after authentication, and may be `async`. Returning `undefined` means no tenant; there is no fallback | | `tenancy: { sources: [...] }` | Ordered built-in sources with optional `required` and membership checks; see [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/) | `tenancy.resolve` overrides built-in sources; `tenancy.httpHeader` customizes the header when using the built-in header source. ```typescript import { ArcApplication, TenantResolverType } from '@cratis/arc.core'; const builder = ArcApplication.createBuilder({ authentication: [/* verified handlers */], tenancy: { sources: [TenantResolverType.Claim, TenantResolverType.Header], claimType: 'tenant', membershipClaim: 'tenants', required: true } }); ``` This tries an own `tenant` claim on the verified principal first, then the header. A missing tenant answers 400; a selected tenant that is not listed in the principal’s comma-separated `tenants` claim answers 403. Both checks run before authorization, validation, or your code. A header is a request, not proof Without `tenancy.membershipClaim` or `tenancy.resolve`, Arc takes the header unchanged and does not check membership. When tenants separate customers’ data, derive the tenant from the principal in `tenancy.resolve`, configure a membership claim, or check it in authorization. Never enforce it in a validator: a trusted direct caller can lower blocking severity. ## Read the tenant Every callback receives the execution context with `tenantId`, `principal`, `correlationId`, `signal`, and `allowedSeverity`. Code without access to that parameter, such as a repository deep in a call chain, can call `currentContext()`. It uses Node.js `AsyncLocalStorage`, so concurrent requests never see each other’s context, and it returns `undefined` outside an Arc execution. In a spec, set the tenant the same way a trusted caller would: `CommandScenario.for(...).withContext({ tenantId: 'acme', principal })`. ## Storage follows the tenant The storage integrations select per-tenant storage from the resolved tenant: * [MongoDB](/arc/backend/typescript/mongodb/tenancy/) chooses a database per tenant, and fails without one. * [SQL with Drizzle](/arc/backend/typescript/sql/tenancy/) calls your `databaseFactory` per tenant, and fails without one. * [Chronicle](/arc/backend/typescript/chronicle/), experimental, appends in the tenant’s namespace, or in `Default` when there is none. [Storage isolation](/arc/backend/typescript/tenancy/isolation/) shows each mapping and how to verify it. ## Best practices * **Derive the tenant from verified identity when tenants are customers.** Use the `claim` source, or `tenancy.resolve` reading the principal. Keep the header for trusted internal callers. * **Always prove membership.** Configure `membershipClaim`, check it in `tenancy.resolve`, or add a policy. Selection alone proves nothing. * **Set `required: true` when every operation is tenant-scoped.** A missing tenant then fails with 400 at the edge, instead of deep in a storage integration. * **Use stable, lowercase tenant IDs.** Built-in sources lowercase IDs and accept only DNS labels of up to 63 characters. Returning the same form from `tenancy.resolve` keeps every integration aligned. * **Put the tenant in cache keys, logs, and telemetry.** A cache keyed only by entity ID serves one tenant’s data to another. * **Keep `fixed` and `development` sources for single-tenant or local setups.** Neither checks where a request came from. ## Security considerations * Tenant resolution runs after authentication, so `tenancy.resolve` and the claim source see a verified principal. Never read identity from the request yourself in a resolver. * A header, query-string, or subdomain value is a request by the caller. The `subdomain` source reads only a host-verified authority, never the raw `Host` or `X-Forwarded-Host` header; see [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/). * Enforce membership in tenancy options or authorization, never in validators. * `/.cratis/tenants` is a fixture list for development tools, anonymous by default only in Development. Never return real tenant inventories from `developmentTenants`; see [Development users and tenants](/arc/backend/typescript/identity/development-users-and-tenants/). * Treat tenant IDs as internal metadata. Avoid putting them in public URLs or error messages when a customer name would reveal who else uses the system. ## Recap * Arc selects one tenant per request and exposes it as `context.tenantId` and through `currentContext()`. * Selection, membership, and storage isolation are separate decisions. Configure all three. * Headers select, principals prove. Next, follow one request through all three decisions in [Tenancy end to end](/arc/backend/typescript/tenancy/end-to-end/), or pick your sources in [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/) and check your storage in [Storage isolation](/arc/backend/typescript/tenancy/isolation/). # Tenancy end to end > Follow one request from tenant selection by header, claim, subdomain, or fixed value, through membership verification, to tenant-scoped MongoDB, Drizzle, and Chronicle reads, and see how long each tenant decision lasts. Acme and Globex share your task service. A request from Ada at Acme has to reach Acme’s tasks, a request that names Globex without belonging to it has to stop before any code runs, and a background import has to know which tenant it writes for. This guide wires those three steps together and shows where each one happens. You need an Arc application with authentication configured; see [Authentication](/arc/backend/typescript/core/authentication/). The storage sections assume the matching integration is registered as its getting-started page shows. ## 1. Choose how a request selects its tenant Selection answers “which tenant does this request name?”. Pick the source that matches how callers reach you: | Source | Use it when | Configuration | | --- | --- | --- | | `header` | A trusted gateway or internal caller sets the tenant | `httpHeader`, default `x-cratis-tenant-id` | | `claim` | Your identity provider puts the tenant in the token | `claimType`, default `tenant_id` | | `subdomain` | Customers reach you at `acme.example.com` | `baseDomain`, and a host-verified authority from the adapter | | `fixed` | The deployment serves one tenant | `fixedTenantId` | `tenancy.sources` tries sources in order and takes the first nonempty answer. [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/) lists every option and validation rule. A header, query string, or subdomain value is what the caller **asks for**. It says nothing about whether the caller may have it. A claim comes from a verified principal, but it is still only a selection until you check membership. ## 2. Verify membership Membership answers “may this caller use the tenant it selected?”. Configure it next to the sources: main.ts ```typescript import { ArcApplication, jwtBearer, TenantResolverType } from '@cratis/arc.core'; const builder = ArcApplication.createBuilder({ authentication: [jwtBearer({ jwksUrl: new URL('https://login.example.com/.well-known/jwks.json'), issuer: 'https://login.example.com/', audience: 'tasks-api', algorithms: ['RS256'] })], tenancy: { sources: [TenantResolverType.Claim, TenantResolverType.Header], claimType: 'tenant_id', membershipClaim: 'tenants', required: true } }); const app = await builder.build(); await app.run(); ``` For each request, Arc authenticates the bearer token, then: * takes the token’s `tenant_id` claim, or the `x-cratis-tenant-id` header when the token has none; * answers **400** when neither is present, because `required` is set; * answers **403** when the selected tenant is not in the token’s comma-separated `tenants` claim. A token for Ada with `"tenants": "acme"` and no `tenant_id` claim can send `x-cratis-tenant-id: acme`, and gets 403 for `globex`. When the token carries `tenant_id`, the claim comes first and selects the tenant, whatever the header says. Both checks run before authorization, validation, and your code. When the rule is more than a claim lookup, write `tenancy.resolve(request, principal)` instead. Its answer is final: Arc does not apply `sources`, `required`, or `membershipClaim` to it, and does not lowercase it. Check membership inside it, derive the tenant from the principal, and return lowercase IDs. Selection is not membership Without `membershipClaim` or a checking `tenancy.resolve`, a header selects any tenant the caller names. A separate database per tenant does not help: Arc routes the request to the database the caller named. Never check membership in a validator, because a trusted direct caller can lower the blocking severity. ## 3. Read the tenant’s data The resolved tenant becomes `context.tenantId`. Every storage integration reads it from the execution, so your query never passes a tenant or picks a database itself. ### MongoDB TaskQueries.ts (excerpt) ```typescript const tasks = mongoCollection(TaskRecord); @readModel() export class TaskQueries { @query(service(tasks)) static async all(items: MongoCollection): Promise { return items.find(); } } ``` With `withMongoDB({ database: 'tasks', ... })`, tenant `acme` reads the `tasks+acme` database and tenant `default` reads `tasks`. A request without a tenant fails. See [MongoDB tenancy](/arc/backend/typescript/mongodb/tenancy/). ### SQL with Drizzle TaskQueries.ts (excerpt) ```typescript @readModel() export class TaskQueries { @query(service(drizzleReadModel(TaskRecord)), queryOptions()) static page(tasks: DrizzleReadModels, options: QueryOptions) { return tasks.queryPage(undefined, options); } } ``` With `withDrizzle({ databaseFactory, ... })`, Arc calls `databaseFactory('acme', context)` for an Acme request and reads through the database it returns. Your factory owns the mapping, the pools, and their credentials. See [Route SQL by tenant](/arc/backend/typescript/sql/tenancy/). ### Chronicle Listing.ts (excerpt) ```typescript @readModel() @fromEvent(AuthorRegistered) export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @query({ observable: true }, service(ChronicleReadModels)) static allAuthors(models: ChronicleReadModels): Observable { return models.observeAll(Author, author => author.id.toString()); } } ``` `ChronicleReadModels` reads from the event store namespace named by the tenant: `acme` for Acme, and Chronicle’s `Default` when there is no tenant. Appends from commands use the same namespace. See [Chronicle read models](/arc/backend/typescript/chronicle/read-models/). None of the three checks membership. They trust the tenant from step 2. [Storage isolation](/arc/backend/typescript/tenancy/isolation/) compares their mappings, including letter case. ## 4. How long a tenant decision lasts ``` StorageService scopeArcClientStorageService scopeArcClientRequest with credential and tenant selectionAuthenticate (401 on a rejected credential)Resolve tenant and check membership (400 or 403)Create a scope for this execution and tenantResolve the tenant's database, connection, or namespaceTenant-scoped handleResultDispose the scope ``` | Execution | When the tenant is resolved | How long it holds | | --- | --- | --- | | HTTP command or query | Once per request, after authentication | Until the request’s scope is disposed | | Observable query over SSE | Once, for that request | For the whole subscription, in the subscription’s own scope | | Observable query over WebSocket or the multiplexed hub | Once, when the connection opens | For every subscription on that connection | | Command returned by a Chronicle reactor | From the namespace of the triggering event | For that command | | Direct call, such as a background job | From the `tenantId` in the context you pass | For that call | Two consequences follow: * **Keep tenant-scoped handles in scoped services.** A singleton that caches a collection, a connection, or query results holds the first tenant that created it. Arc’s build check rejects a singleton that declares a scoped dependency, but not a cache you build yourself. * **Pass a tenant on purpose in background work.** A job calling `app.server.executeCommand(name, input, context)` supplies the whole context, including `tenantId`; see [Calling commands from code](/arc/backend/typescript/commands/calling-commands-from-code/). Arc does not verify membership for a context you construct. Code deep in a call chain can read the current tenant with `currentContext()?.tenantId`. It returns `undefined` outside an Arc execution. ## Check it * Send a request for each of your tenants and a request without one, and check that each read lands in the expected database or namespace. * Send a request that names a tenant the caller does not belong to, and check for 403 with nothing read or written. * Run two tenants’ requests concurrently, including an observable query, and check that neither sees the other’s data. `CommandScenario.withContext({ tenantId })` proves your code uses the tenant it receives. It does not exercise your resolver or storage configuration, so keep at least one check over HTTP with real storage. ## Related * [Tenancy](/arc/backend/typescript/tenancy/) * [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/) * [Storage isolation](/arc/backend/typescript/tenancy/isolation/) * [Identity contracts](/arc/backend/typescript/identity/contracts/) for the principal the claim source reads # Storage isolation > See how the MongoDB, Drizzle, and experimental Chronicle integrations turn the resolved tenant into a database, connection, or event store namespace, and how to verify the boundary. Resolving `acme` for a request is only useful if the data really goes to Acme’s storage. Each storage integration maps the resolved tenant to its own destination, and they do not all treat a missing tenant or letter case the same way. This page lists each mapping so you can line them up, then shows how to check that the boundary holds. ## How each integration maps the tenant | Integration | Destination for tenant `acme` | Tenant `default` | No tenant | | --- | --- | --- | --- | | [MongoDB](/arc/backend/typescript/mongodb/tenancy/), `database: 'tasks'` | Database `tasks+acme` | Database `tasks` | The request fails | | [SQL with Drizzle](/arc/backend/typescript/sql/tenancy/) | The connection `databaseFactory('acme', context)` returns | `databaseFactory('default', context)`, or the `database` option when there is no factory | The request fails | | [Chronicle](/arc/backend/typescript/chronicle/), experimental | Namespace `acme` in the configured event store | Namespace `default` | Namespace `Default` | MongoDB and Drizzle lowercase the tenant ID before they use it. The Chronicle integration passes the ID to Chronicle as it is. None of these checks membership. They trust the tenant Arc resolved, which is why [Tenancy](/arc/backend/typescript/tenancy/) asks you to prove membership first. ## The Chronicle namespace rule With `@cratis/arc.chronicle`, every append, and every read the integration makes through `ChronicleReadModels`, uses the event store namespace named by `context.tenantId`. When the execution has no tenant, it uses Chronicle’s `Default` namespace. Arc does not rename tenants on the way: a built-in source that resolved `default` sends `default`, which is a different string from `Default`. Commands that a reactor returns run in the namespace of the event that triggered them. Arc sets their `tenantId` to that namespace, so a reactor reacting to Acme’s event can only produce commands for Acme. To keep MongoDB, Drizzle, and Chronicle pointing at the same tenant, use the lowercase IDs that the built-in sources produce. If you write `tenancy.resolve`, return lowercase IDs too. The Library sample shows the single-tenant case: `tenancy: { resolve: () => 'Default' }` sends every request to Chronicle’s `Default` namespace. ## Verify the boundary Storage naming can look right while data still crosses tenants through a cache, a long-lived handle, or a background job. Test the boundary itself, with real storage where you can: * **Four tenant states.** No tenant, the default tenant, and two named tenants. Assert both the answer and where the data landed. * **A denied selection.** A caller naming a tenant they do not belong to gets 403, and nothing is read or written. * **Concurrent requests.** Run requests for two tenants at the same time. `currentContext()` is per request, but a singleton that caches a collection, a connection, or query results is not. * **Long-lived work.** Include observable queries, reactor-driven commands, and anything that runs after the request, since those carry the tenant they started with. * **Direct calls.** A job that calls `app.server` directly passes its own context; check that it passes a tenant on purpose. In specs, `CommandScenario.withContext({ tenantId })` sets the tenant for pipeline calls. It proves your code uses the tenant it is given. It does not prove your resolver or your storage configuration, so keep at least one check that goes through HTTP and real storage. ## Related * [Tenancy](/arc/backend/typescript/tenancy/) * [Tenant resolvers](/arc/backend/typescript/tenancy/resolvers/) * [MongoDB tenancy](/arc/backend/typescript/mongodb/tenancy/) * [SQL tenancy](/arc/backend/typescript/sql/tenancy/) # Tenant resolvers > Select a tenant from a header, query string, trusted claim, fixed value, or verified subdomain, in the order you choose, with optional required and membership checks. Where does the tenant come from: a header your gateway sets, a claim in the user’s token, or the `acme` in `acme.example.com`? Pick the source, or an ordered list of sources, that matches how your callers arrive. Set `tenancy.resolverType` for one .NET-compatible source, or `tenancy.sources` for an ordered list. The first nonempty result wins. Do not set both. A header or query-string value is a **selection**, not proof of authority. ## Sources | Source | Configuration | Reads | | --- | --- | --- | | `header` | `tenancy.httpHeader` (default `x-cratis-tenant-id`) | Request header | | `query` | `queryParameter` (default `tenantId`) | Request query string | | `claim` | `claimType` (default `tenant_id`) | An own string claim on an authenticated principal | | `fixed` | `fixedTenantId` (default `development`) | A configured deployment value | | `development` | `fixedTenantId` (default `development`) | The same fixed source as in .NET; it does **not** check your environment | | `subdomain` | Required `baseDomain` | The verified adapter `native.authority`, **not** the raw `Host` or `X-Forwarded-Host` header | Import `TenantResolverType` from `@cratis/arc.core`. For a single-tenant application, `resolverType: TenantResolverType.Fixed, fixedTenantId: 'default'` gives every request the `default` tenant. ## Options | Option | Effect | | --- | --- | | `resolverType` / `sources` | One source (defaults to `header` when `tenancy` is supplied) / an ordered list; they cannot be combined | | `required` | An unresolved tenant answers 400 | | `membershipClaim` | A selected tenant requires an authenticated principal whose own claim of this name, a comma-separated list, includes it; otherwise 403 | | `queryParameter`, `claimType`, `fixedTenantId`, `baseDomain` | Per-source settings | ## Validation rules * Nonempty IDs are lowercased and must be DNS labels: letters, digits, and hyphens, at most 63 characters. An invalid selected ID answers 400. * A nonstring selected tenant claim answers 400; a nonstring membership claim answers 403. Strategies read only own string values from the claim object. * `subdomain` requires an ASCII `baseDomain` of at least two labels and a host-verified `authority` from the adapter callback. Only a single subdomain label matches; IP addresses, unrelated or multi-label hosts, and raw `Host` or forwarded headers never do. * The header is the default source when `tenancy` is supplied; no development or fixed strategy is installed implicitly. Write `sources: [TenantResolverType.Subdomain, TenantResolverType.Header]` to fall back explicitly. * Other strategy errors and unsafe startup options fail closed. Never use `development` or `fixed` to accept a browser-supplied tenant without validating access. `tenancy.resolve` overrides these sources, and the legacy header behavior applies when `tenancy` is absent; see [Tenancy](/arc/backend/typescript/tenancy/). ## Related * [Storage isolation](/arc/backend/typescript/tenancy/isolation/) for where the resolved tenant sends data * [Native principal](/arc/backend/typescript/hosts/native-principal/) for supplying a verified authority * [Authentication](/arc/backend/typescript/core/authentication/) # Testing > Test commands, queries, and observable queries through Arc's real pipelines without starting a server, and choose the boundary that can catch the bug you care about. A spec that calls `handle()` directly skips everything Arc does around it: binding, authorization, validators, services, and the result envelope. A spec that starts an HTTP server is slow and fragile. `@cratis/arc.testing` runs your artifacts through the **real** pipelines in-process, so a passing spec means the behavior a client sees, without a listener or a port. A useful spec still answers one precise question. Is the decision right? Did Arc enforce the rule? Did the operation get undone? Each question has a boundary that answers it cheaply. ## Choose the boundary that can catch the bug | What you need to prove | Start with | It does not prove | | --- | --- | --- | | A calculation or decision | A direct `handle()` spec with explicit inputs | Validation, authorization, `provide()`, or services | | Validation, authorization, `provide()`, services, and the response | `CommandScenario` | HTTP routing, authentication handlers, or real infrastructure | | Operations executing and compensating in order | `CommandScenario` with fake providers | That a real provider undid anything | | A query’s arguments, paging, sorting, and result shape | `QueryScenario` | The database’s own query behavior | | A live query’s emissions | `ObservableQueryScenario` | A transport or a browser client | | The route, the host, and authentication | `ArcScenario` with HTTP requests | Business edge cases you did not send | | Events a command appends for Chronicle, from pinned or supported seeded read models | `ChronicleCommandScenario` | Aggregates, unsupported projections, constraints, or observer-driven updates | | A command that depends on stored events or an aggregate | `ChronicleKernelScenario` against a running kernel | Replays or concurrent writers after the check | Combine boundaries rather than pushing every case through the widest one. Cover decision branches with fast direct specs, add scenario specs for the Arc contracts that matter, and keep a smaller set of HTTP or integration tests for real composition. ## Learn by doing Two lessons build the habit step by step, each with runnable specs: * [Test a command’s decision and its pipeline](/arc/backend/typescript/testing/command-decisions/) tests one shipping-quote command both ways: directly for the arithmetic, through `CommandScenario` for validation and the service call. * [Test operations and compensation](/arc/backend/typescript/testing/command-operations/) proves that Arc runs a command’s operations, stops after a provider failure, and compensates in reverse order. ## Pick a scenario | Scenario | Use it for | Page | | --- | --- | --- | | `CommandScenario` | A decorated command, its validators, services, authorization, and operations | [Commands](/arc/backend/typescript/testing/commands/) | | `QueryScenario` | A decorated static query, with arguments, paging, and sorting | [Queries](/arc/backend/typescript/testing/queries/) | | `ObservableQueryScenario` | A decorated observable query, collecting emissions with a deadline | [Observable queries](/arc/backend/typescript/testing/observable-queries/) | | `ArcScenario` | Low-level definitions and full HTTP requests | [Low-level definitions](/arc/backend/typescript/testing/low-level-definitions/) | | `ChronicleCommandScenario` | Commands that return Chronicle events, without a kernel | [Chronicle](/arc/backend/typescript/testing/chronicle/) | | `ChronicleKernelScenario` | Chronicle commands with seeded events, aggregates, projections, and constraints | [Chronicle kernel scenarios](/arc/backend/typescript/testing/chronicle-kernel/) | Every scenario builds its application lazily on the first call, lets you register fakes first, and must be disposed with `await scenario.dispose()`; disposal is idempotent. For the built-in Node.js runner, [run a CommandScenario with node:test](/arc/backend/typescript/testing/node-test/) using `after` for disposal. ## Share a context with given() The Tasks sample’s specs use `given(Context, context => { ... })` from `@cratis/arc.testing` to create one context per spec suite, without depending on Mocha types: Features/Tasks/Registration/for_RegisterTask/given/a_task_registration.ts ```typescript import { CommandScenario } from '@cratis/arc.testing'; import { Tasks } from '../../../Tasks.js'; import { RegisterTask, RegisterTaskValidator } from '../../Registration.js'; import { metadata } from '../../../../generatedMetadata.js'; export class a_task_registration { tasks = new Tasks(); scenario = CommandScenario.for(RegisterTask, RegisterTaskValidator); constructor() { this.scenario.extend(builder => builder.useGeneratedMetadata(metadata)); this.scenario.services.addSingleton(Tasks, this.tasks); } } ``` `extend(...)` installs anything the application’s builder needs before the scenario builds it; here, the generated metadata that binds `handle(tasks: Tasks)` without `@inject`. Because `given(...)` creates **one** context for the whole `describe`, run the action in `beforeAll` and dispose in `afterAll`. A scenario disposed after the first test cannot run again. The `for_/when_/.ts` layout follows the Cratis specification conventions; each spec file reads as a sentence. Run the sample’s specs with `yarn vitest run Samples/Tasks`. ## Wire round trips Scenario inputs are always encoded to Arc’s wire representation before the pipeline decodes them, exactly as over HTTP. By default inputs and returned data also pass through JSON `stringify` and `parse`, so a concept in a response arrives as its primitive value. `withSerializationRoundTrip(false)` skips only that JSON step and keeps wire encoding. ## Next step Start with [Test a command’s decision and its pipeline](/arc/backend/typescript/testing/command-decisions/), or go straight to [Testing commands](/arc/backend/typescript/testing/commands/) for every assertion. ## Related * [Calling commands from code](/arc/backend/typescript/commands/calling-commands-from-code/), the entry points scenarios build on * [Dependency injection](/arc/backend/typescript/dependency-injection/) # Testing Chronicle commands > Test event-sourced Arc commands without a kernel - seed supported read-model history, pin read models, and assert appended events. An event-sourced command makes a decision and records it as events. A test for it answers three questions: what state did the command see, which events did it append, and what did it refuse. `@cratis/arc.chronicle/testing` has two scenarios for this, and this page covers the in-memory one. Both run the command through the real Arc pipeline: authorization, validation, `provide()`, `handle()`, and the Chronicle response handler. Experimental The Chronicle integration and its testing helpers are experimental. See [Chronicle](/arc/backend/typescript/chronicle/) for their status. ## Choose a scenario | | `ChronicleCommandScenario` | `ChronicleKernelScenario` | | --- | --- | --- | | Needs | Nothing; the event log is in memory | A running Chronicle kernel at `ARC_CHRONICLE_TEST_URL` | | Runs in `yarn test` | Yes | Only when you opt in | | State the command reads | Pinned models or reducer/flat projection state from seeded events | Events you seed with `given.events`, projected by the kernel | | Aggregates (`commandAggregate`) | Not supported; the command fails | Rehydrated from the seeded events | | Constraints and concurrency | Not enforced | Enforced | | Projections | Supported flat definitions evaluated on demand; see the [Chronicle testing capabilities](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/testing.md#projection-capabilities) | Run by the kernel; assert them with `shouldHaveReadModel` | Start with `ChronicleCommandScenario`. Most tests check the decision a command makes from the state it is given, and the in-memory scenario checks that in milliseconds. For snapshot queries injecting `ChronicleReadModels`, [ChronicleQueryScenario](/arc/backend/typescript/testing/queries/#query-chronicle-read-models-in-memory) reuses the same pin and seeded-history rules for keyed lookups. Move to `ChronicleKernelScenario` when the test depends on an aggregate, an unsupported projection operation, a constraint, or a concurrency check; [Test Chronicle commands against a kernel](/arc/backend/typescript/testing/chronicle-kernel/) covers it. ## The slice under test The examples on this page test a lending slice built on the [Library sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Library). The slice is written for this page and is not part of the sample. It uses the sample’s `Book` read model, which a projection builds from `BookAdded`, and its `BookId` and `BookTitle` concepts. One file holds the command, its validator, and its events: Features/Books/Lending/Lending.ts ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, commandReadModel, CommandValidator, inject, key, validator } from '@cratis/arc.core'; import { BookId } from '../BookId.js'; import { BookTitle } from '../BookTitle.js'; import { Book } from '../Listing/Listing.js'; @eventType() export class BookLent { @field(BookTitle) title: BookTitle; @field(String) member: string; constructor(title: BookTitle = new BookTitle(''), member = '') { this.title = title; this.member = member; } } @eventType() export class ReturnDateSet { @field(Number) days: number; constructor(days = 0) { this.days = days; } } @command() export class LendBook { @key() @field(BookId) bookId!: BookId; @field(String) member!: string; @field(Number) days!: number; @inject(commandReadModel(Book)) handle(book: Book): [BookLent, ReturnDateSet] { return [new BookLent(book.title, this.member), new ReturnDateSet(this.days)]; } } @validator(LendBook) export class LendBookValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.member).notEmpty().withMessage('A member is required'); this.ruleFor(command => command.days).greaterThan(0).withMessage('A loan lasts at least one day'); } } ``` `LendBook` reads the `Book` projected for its key and returns two events. Arc appends both to the book’s event source in one batch. For an application that loads this slice, [injecting an aggregate into a command](/arc/backend/typescript/chronicle/aggregates/injecting-into-commands/) shows `useGeneratedMetadata` and `discover()` together. ## Seed reducer history Register the event, read model, and its `@reducer(..., undefined, ReadModel)` type as artifacts. Seed events before execution: Features/Accounts/for_CheckAccount/when_checking/with_opened_account.ts ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { reducer } from '@cratis/chronicle/reducers'; import { readModel } from '@cratis/chronicle/readModels'; import { command, commandReadModel, inject, key } from '@cratis/arc.core'; import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing'; @eventType() class AccountOpened { @field(Number) balance: number; constructor(balance = 0) { this.balance = balance; } } @readModel() class AccountBalance { @field(Number) balance = 0; } @reducer('AccountBalanceReducer', undefined, AccountBalance) class AccountBalanceReducer { accountOpened(event: AccountOpened): AccountBalance { return { balance: event.balance }; } } @command() class CheckAccount { @field(String) @key() id = ''; @inject(commandReadModel(AccountBalance)) handle(balance: AccountBalance): number { return balance.balance; } } const scenario = ChronicleCommandScenario.for(CheckAccount, AccountOpened, AccountBalance, AccountBalanceReducer); scenario.given.forEventSource('account-1').events(new AccountOpened(25)); const result = await scenario.execute({ id: 'account-1' }); result.shouldBeSuccessful(); await scenario.dispose(); ``` Here `AccountBalanceReducer` handles `AccountOpened` and produces the `AccountBalance` injected into `CheckAccount`. The SDK’s `ReadModelScenario` folds reducer history (since 6.14.0) and, starting in 6.19.0, evaluates supported flat projections from seeded events on demand for each source. The main `@cratis/arc.chronicle` entry supports `@cratis/chronicle` 6.29.0 and later. The scenario loads the SDK’s testing subpath only when it needs to fold seeded history. A different source has no balance; required `commandReadModel(AccountBalance)` rejects it and an optional read model receives `null`. Seeding does not appear in `result.appendedEvents` or `scenario.appendedEvents`. Later command appends are **not** folded into this scenario’s read models, matching the .NET command scenario’s seeded-history lookup. Use a kernel scenario to test observer updates caused by commands. Seed history for a different tenant with `scenario.given.forEventSource('account-1', 'tenant-a').events(...)` and set that tenant in the command’s trusted context before executing. `given.forEventSource(id).readModel(instance)` pins state for the current tenant instead; `givenReadModel(Type, id, instance, tenant?)` also defaults to the current tenant. If you select a source before setting `scenario.context.tenantId`, the default tenant is resolved when you call `.events(...)` or `.readModel(...)`. A pinned instance wins over history for its type, source and tenant. Projection seeds retain chronological order across source IDs, so projected `EventContext.SequenceNumber` reflects the order of `.events(...)` calls. Reducer seeds retain their existing per-source feeding order; interleaved sources can therefore have different sequence numbers in reducer contexts. With `@cratis/chronicle` 6.19.0 or later, a projection-backed model with seeded history is evaluated in-process within the [Chronicle testing capability boundary](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/testing.md#projection-capabilities). An unsupported definition throws the SDK’s `UnsupportedProjectionOperation` unchanged; use `ChronicleKernelScenario` for that definition. Older SDKs require a kernel scenario for projection-backed state and report the required SDK version instead of fabricating a model. If a projection omits its read-model type, seeded lookups on older SDKs fail explicitly with `@cratis/chronicle >= 6.19.0` and suggest `@projection('', ReadModel)` because Arc cannot tell which read model it targets without the evaluator. With no history for that source, the model is missing just like an unseeded reducer-backed model. A plain `@readModel()` without a reducer or projection is also missing when the SDK can rule out untyped projections for it (or none are registered), even if other models have seeded history. Aggregates also require the kernel scenario because the in-memory event log cannot replay their routed event history. This is not a substitute for constraints, concurrency, compliance or reactors. ## Pin the read model a command reads `givenReadModel(Type, sourceId, instance)` sets the instance the scenario’s event store returns for that read model and ID. Pin it before `execute`: Features/Books/Lending/for_LendBook/when_lending/with_a_book_in_the_catalog.ts ```typescript import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing'; import { AuthorId } from '../../../../Authors/AuthorId.js'; import { BookId } from '../../../BookId.js'; import { BookTitle } from '../../../BookTitle.js'; import { Book } from '../../../Listing/Listing.js'; import { BookLent, LendBook, LendBookValidator, ReturnDateSet } from '../../Lending.js'; describe('when lending a book in the catalog', () => { const scenario = ChronicleCommandScenario.for(LendBook, BookLent, ReturnDateSet, LendBookValidator, Book); const bookId = BookId.create(); let result: Awaited>; beforeAll(async () => { const book = new Book(); book.id = bookId; book.authorId = AuthorId.create(); book.title = new BookTitle('Kindred'); scenario.givenReadModel(Book, bookId.toString(), book); result = await scenario.execute({ bookId, member: 'member-1', days: 14 }); }); afterAll(async () => { await scenario.dispose(); }); it('should append the loan and its return date to the book', () => { result.shouldBeSuccessful(); result.appendedEvents.should.have.lengthOf(2); result.shouldHaveAppendedEvent(BookLent, bookId.toString(), event => event.title.value === 'Kindred' && event.member === 'member-1'); result.shouldHaveAppendedEvent(ReturnDateSet, bookId.toString(), event => event.days === 14); }); it('should append the events in the order handle() returned them', () => { result.appendedEvents.map(appended => appended.event.constructor).should.deep.equal([BookLent, ReturnDateSet]); }); }); ``` Pass everything the command touches to `for(...)`: its event types, validators, and read model types. The in-memory log refuses an event type that is not registered. A read model type that is not registered fails the execution with `Expected one read-model resolver for Book, found 0`. A pinned read model belongs to one ID and one tenant: * The tenant defaults to the current `scenario.context.tenantId`, or `Default` when the context sets none. Pass the tenant as the fourth argument to pin a model in a different tenant. * A read model that is neither pinned nor materialized from supported seeded history is missing. `commandReadModel(Book)` rejects the command, and `commandReadModel(Book, { optional: true })` hands `handle()` a `null`. * A command that injects `ChronicleReadModels` and calls `findInstanceById` or `getById` receives the pinned instance for any ID, not only the command key. `getAll` and the observe methods are not backed by the in-memory store. A pinned read model is a fixed value. The scenario does not evaluate history for that pinned type, and a later execution does not update it. To check observer-driven updates or projections outside the in-process capability boundary, use the [kernel scenario](/arc/backend/typescript/testing/chronicle-kernel/#assert-a-projection). ## Assert several appended events A result carries the events appended by **that** execution: * `result.appendedEvents` lists them in the order the command returned them. Each entry has the `event` instance, its `source`, `tenant`, `eventSourceType`, `eventStreamType`, `eventStreamId`, `subject`, and `tags`. * `result.shouldHaveAppendedEvent(Type, sourceId?, predicate?)` passes when at least one of them matches. Call it once per event you expect. `shouldHaveAppendedEvent` does not check how many events were appended or in which order. Assert the count on `appendedEvents` when an extra event would be a defect, and compare the event types when the order matters, as the example above does. `scenario.appendedEvents` holds every event appended since the scenario was created. Use it only when a test runs several commands and asserts their combined output. ## Assert that nothing was appended A rejected command appends nothing. Assert the reason and the empty result: ```typescript describe('when lending a book that is not in the catalog', () => { const scenario = ChronicleCommandScenario.for(LendBook, BookLent, ReturnDateSet, LendBookValidator, Book); let result: Awaited>; beforeAll(async () => { result = await scenario.execute({ bookId: BookId.create(), member: 'member-1', days: 14 }); }); afterAll(async () => { await scenario.dispose(); }); it('should reject the command without appending', () => { result.shouldNotBeSuccessful(); result.shouldHaveValidationErrorFor('Book was not found for the command key'); result.appendedEvents.should.have.lengthOf(0); }); }); describe('when lending a book for no days', () => { const scenario = ChronicleCommandScenario.for(LendBook, BookLent, ReturnDateSet, LendBookValidator, Book); let result: Awaited>; beforeAll(async () => { result = await scenario.execute({ bookId: BookId.create(), member: 'member-1', days: 0 }); }); afterAll(async () => { await scenario.dispose(); }); it('should reject the loan period without appending', () => { result.shouldHaveValidationErrorForMember('days'); result.appendedEvents.should.have.lengthOf(0); }); }); ``` These blocks continue the spec file above. The second one pins no read model and still gets a clean validation failure, because validators run before Arc loads the read model for `handle()`. The [command assertions](/arc/backend/typescript/testing/commands/#assertions) work on every Chronicle result. To run a command as a signed-in user, set the principal on `scenario.context`, as the Library sample’s [`RegisterAuthor` spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Registration/for_RegisterAuthor/when_registering/with_librarian_role.ts) does. ## What the scenario offers | Member | Meaning | | --- | --- | | `for(Command, ...artifacts)` | Create the scenario; pass event types, validators, and read models | | `context` | Trusted request values, such as the principal and tenant | | `given.forEventSource(sourceId, tenant?).events(...events)` | Seed ordered history for a source and tenant (default: current context tenant) | | `given.forEventSource(sourceId, tenant?).readModel(instance)` | Pin a read model by its instance type | | `givenReadModel(Type, sourceId, instance, tenant?)` | Pin a read model instance; the tenant defaults to the current context tenant (or `Default`) | | `execute(values)` | Run the command; the result has the usual [command assertions](/arc/backend/typescript/testing/commands/#assertions) | | `result.appendedEvents` | The events this execution appended | | `result.shouldHaveAppendedEvent(Type, sourceId?, predicate?)` | An event of that type was appended in this execution, optionally to a source and matching a predicate | | `scenario.appendedEvents` | Every event appended since the scenario was created | | `dispose()` | Release the scenario; call it after every test | ## What the in-memory scenario does not do The in-memory log records the events a command appends, with their routing, subject, and tags, and it accepts concurrency scopes. Its tail sequence number counts command appends only, not seeded history. It does not enforce concurrency or constraints, run observers or reactors, load aggregates, or replace the kernel suite. Supported flat projections are evaluated on demand for seeded keyed reads, not kept up to date by command appends. It can seed reducer and supported flat projection history, but an aggregate cannot load it, and commands do not update read-model state in this fixture. When the decision depends on unsupported projection or aggregate history, use the [kernel scenario](/arc/backend/typescript/testing/chronicle-kernel/#seed-an-event-sources-history), which seeds a book’s loan and returns it through an aggregate. `Source/Chronicle/run-integration.sh` covers adapter-level integration checks. ## Related * [Chronicle commands](/arc/backend/typescript/chronicle/commands/) * [Read models in commands](/arc/backend/typescript/chronicle/read-models/injecting-into-commands/) * [Aggregates](/arc/backend/typescript/chronicle/aggregates/) * [Test Chronicle commands against a kernel](/arc/backend/typescript/testing/chronicle-kernel/) * [Testing commands](/arc/backend/typescript/testing/commands/) # Test Chronicle commands against a kernel > Seed an event source's history, run Arc commands against a live Chronicle kernel, and assert aggregates, projections, constraints, and concurrency in an isolated event store. Some command behavior only exists in the kernel. An aggregate needs stored history to load; a constraint or concurrency check is enforced when Chronicle appends; and projections outside the [in-process Chronicle testing capabilities](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/testing.md#projection-capabilities) need a kernel. The [in-memory scenario](/arc/backend/typescript/testing/chronicle/) evaluates supported flat projections on demand for keyed reads, but does not run observers or update read models after command appends. An unsupported definition throws `UnsupportedProjectionOperation`: use this kernel scenario rather than bypassing that boundary. `ChronicleKernelScenario` from `@cratis/arc.chronicle/testing` runs the command through the real Arc pipeline against a running kernel, in a fresh event store for each scenario. Experimental The Chronicle integration and its testing helpers are experimental. A kernel scenario needs a running Chronicle kernel, so it is **not** part of `yarn test` or the default `yarn ci` check. ## Point the scenario at a kernel A scenario connects to the connection string in `ARC_CHRONICLE_TEST_URL`, or to the `connectionString` option you pass to `for(...)`. Without either, `for(...)` throws `ARC_CHRONICLE_TEST_URL is required for a kernel scenario`. It never starts a kernel itself. In your own application, start a development kernel as [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/#start-a-development-kernel) shows, and run your specs with `ARC_CHRONICLE_TEST_URL=chronicle://localhost:35000`. In this repository, one command starts a kernel and runs the Library sample’s kernel suite: ```shell yarn workspace @cratis/arc.sample.library test:kernel ``` The script starts `cratis/chronicle:latest-development` on a free loopback port with a unique container name, then stops and removes **only that container** on exit. To use a kernel that is already running, set `ARC_CHRONICLE_TEST_URL` to its `chronicle://host:port` connection string; the script then leaves that kernel running. A missing Docker service or an unreachable kernel fails the check instead of skipping it. For `bash Source/Chronicle/run-integration.sh` against a kernel that is already running, also set `ARC_CHRONICLE_TEST_MONGO_URL` to its MongoDB connection string, for example `mongodb://localhost:27017/?directConnection=true`. That suite checks ciphertext in raw storage and fails early without the URL. ## Seed an event source’s history When a decision depends on stored history, seed it with `given.events(...)`. An aggregate is the usual case. This slice returns a book. The `Loan` aggregate replays the book’s loans, and `handle()` rejects the return of a book that is not lent. `BookLent` comes from the lending slice on [Testing Chronicle commands](/arc/backend/typescript/testing/chronicle/#the-slice-under-test): Features/Books/Returning/Returning.ts ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, inject, key, rejected, validation } from '@cratis/arc.core'; import { AggregateRoot, commandAggregate } from '@cratis/arc.chronicle'; import { BookId } from '../BookId.js'; import { BookLent } from '../Lending/Lending.js'; @eventType() export class BookReturned { @field(String) member: string; constructor(member = '') { this.member = member; } } export class BookIsNotLent extends Error { constructor() { super('The book is not lent'); } } export class Loan extends AggregateRoot { borrower: string | null = null; constructor() { super(); this.on(BookLent, event => { this.borrower = event.member; }); this.on(BookReturned, () => { this.borrower = null; }); } returnBook(): void { if (this.borrower === null) throw new BookIsNotLent(); this.apply(new BookReturned(this.borrower)); } } @command() export class ReturnBook { @key() @field(BookId) bookId!: BookId; @inject(commandAggregate(Loan)) handle(loan: Loan) { if (loan.borrower === null) return rejected(validation('The book is not lent')); loan.returnBook(); } } ``` The test seeds a `BookLent` for the book, returns it, and then tries again: Features/Books/Returning/for_ReturnBook/when_returning/with_a_lent_book.ts ```typescript import { describe, it } from 'vitest'; import { ChronicleKernelScenario } from '@cratis/arc.chronicle/testing'; import { BookId } from '../../../BookId.js'; import { BookTitle } from '../../../BookTitle.js'; import { BookLent } from '../../../Lending/Lending.js'; import { BookReturned, ReturnBook } from '../../Returning.js'; const withKernel = process.env.ARC_CHRONICLE_TEST_URL ? describe : describe.skip; withKernel('when returning a lent book', () => { it('should append the return for the member who borrowed it', { timeout: 60_000 }, async () => { const scenario = ChronicleKernelScenario.for(ReturnBook, [BookLent, BookReturned]); try { const bookId = BookId.create(); await scenario.given.events({ eventSourceId: bookId.toString(), event: new BookLent(new BookTitle('Kindred'), 'member-1') }); const returned = await scenario.execute({ bookId }); returned.shouldBeSuccessful(); returned.shouldHaveAppendedEvent(BookReturned, bookId.toString(), event => event.member === 'member-1'); const again = await scenario.execute({ bookId }); again.shouldNotBeSuccessful(); again.shouldHaveValidationErrorFor('The book is not lent'); again.appendedEvents.should.have.lengthOf(0); } finally { await scenario.dispose(); } }); }); ``` How the pieces work: * `for(Command, artifacts)` takes the artifacts as an **array**, unlike the in-memory scenario. Register every event type the test seeds or the command appends. The aggregate class is not registered. * `given.events(...)` appends the seeded events, each as `{ eventSourceId, event }`, and waits for the kernel’s observers before it returns. Seed the event source that the command key names; the aggregate loads that source. * `result.appendedEvents` holds only the events this execution appended, never the seeded ones. * A failed `given.events(...)` throws, so a broken seed fails the test instead of passing silently. * The `{ timeout: 60_000 }` option gives the kernel time. The first execution against a fresh event store can take several seconds, longer than the Vitest default of five seconds. * Without `ARC_CHRONICLE_TEST_URL`, the suite is skipped, so `yarn test` stays kernel-free. The second `execute` replays both the seeded `BookLent` and the `BookReturned` the first one appended, so the aggregate has no borrower, and `handle()` answers with a validation result. `returnBook()` also throws `BookIsNotLent` as a guard for a caller that skips the check; see [Reject a change](/arc/backend/typescript/chronicle/aggregates/defining-an-aggregate-root/#reject-a-change). ## Assert a projection Seeding drives the kernel’s projections, including definitions and lifecycle behavior outside the [in-process capability boundary](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/testing.md#projection-capabilities). Register the read model with the scenario, and assert it with `shouldHaveReadModel(Type, id, predicate?)`. The [Library suite](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/kernel-scenarios.test.mjs) seeds a `BookAdded`, checks the `Book` read model it projects, executes `AddBook`, and checks the new book: ```typescript const scenario = ChronicleKernelScenario.for(AddBook, [BookAdded, Book, AddBookValidator, BookTitleValidator]); try { const seededId = BookId.create(); await scenario.given.events({ eventSourceId: seededId.toString(), event: new BookAdded(authorId, title) }); await scenario.shouldHaveReadModel(Book, seededId.toString()); const result = await scenario.execute({ bookId, authorId, title }); result.shouldBeSuccessful(); result.shouldHaveAppendedEvent(BookAdded, bookId.toString()); await scenario.shouldHaveReadModel(Book, bookId.toString()); } finally { await scenario.dispose(); } ``` This is an excerpt; `AddBook`, `BookAdded`, `Book`, and the value types come from the Library sample. `shouldHaveReadModel` reads the kernel’s read model once and does not poll. It can rely on the read model being current because `given.events(...)` and `execute(...)` wait for the kernel’s observers before they return. That wait covers the observers that processed the append, **or failed** on it. A timeout or a failed observer fails the scenario, instead of claiming that the read model is ready. The wait is not a transaction across observers and side effects, and a later replay can still change the model. A scenario cannot recover a failed observer by sleeping; diagnose it in Chronicle instead. ## Assert a constraint or concurrency rejection A command whose append Chronicle rejects is not successful, and nothing is appended. Assert it with `result.shouldNotBeSuccessful()`, and read the reason from `result.validationResults`: `constraintViolation` for a [constraint](/chronicle/constraints/), `concurrencyViolation` for a [concurrency scope](/arc/backend/typescript/chronicle/commands/concurrency/). The Library suite registers the `UniqueAuthorName` constraint, executes `RegisterAuthor` twice with the same name, and expects `constraintViolation` on the second. Set trusted request values, such as a librarian principal, on `scenario.context`, as the in-memory scenario does. ## Register only the observers the scenario needs After every append, the scenario waits until **every** observer registered in its event store has processed the append tail. Chronicle currently includes observers that do not handle the appended event type, and such an observer never reaches that tail. The wait then runs for the full `timeoutMs` and fails, although the events were appended and the relevant projections ran. This is [Cratis/Chronicle#4132](https://github.com/Cratis/Chronicle/issues/4132). A projection or reactor you register in a scenario must therefore handle every event type the scenario seeds or appends. The `Book` projection in the example above handles `BookAdded`, the only event that scenario appends. If a test also needs events the projection ignores, for example seeding `BookAdded` and then executing `LendBook`, which appends `BookLent`, split it: one scenario registers `Book` and checks the projection, another leaves `Book` out and checks the loan. The same limitation applies to an application that sets `completionTimeoutMs`; see [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/#make-the-command-wait). ## Scenario members | Member | Meaning | | --- | --- | | `for(Command, artifacts, { connectionString?, timeoutMs? })` | Create the scenario against a fresh event store. `connectionString` defaults to `ARC_CHRONICLE_TEST_URL`, and the observer wait `timeoutMs` to 10000 | | `context` | Trusted request values, such as the principal | | `given.events(...events)` | Append seed events, each as `{ eventSourceId, event }`, and wait for the observers | | `execute(values)` | Run the command and wait for the observers; the result has the usual [command assertions](/arc/backend/typescript/testing/commands/#assertions) | | `result.appendedEvents` | The events this execution appended | | `result.shouldHaveAppendedEvent(Type, sourceId?, predicate?)` | An event of that type was appended in this execution, optionally to a source and matching a predicate | | `shouldHaveReadModel(Type, id, predicate?)` | The kernel holds a matching read model; returns it | | `store()` | The scenario’s SDK event store, for reads the helpers do not cover | | `eventStoreName` | The name of the scenario’s event store | | `dispose()` | Release the scenario and its SDK client; call it after every test | Each scenario creates a new event store with a random name and uses the `Default` namespace. `dispose()` does not delete the event store from the kernel; a development kernel loses it when you remove the container. ## Related * [Testing Chronicle commands](/arc/backend/typescript/testing/chronicle/) * [Aggregates](/arc/backend/typescript/chronicle/aggregates/) * [Choose Chronicle read consistency](/arc/backend/typescript/queries/read-consistency/) # Test a command's decision and its pipeline > Learn when to call handle() directly and when to run CommandScenario, using one shipping-quote command that separates acquiring data from deciding. A shipping quote has two different things to prove: **the calculation is right**, and **Arc validates the input and fetches the rate before calculating**. You need no server for either, but they are different tests. A spec that only runs the whole pipeline is slow to write for every tariff edge case; a spec that only calls the method never notices when a validator stops running. In this lesson you write one fast spec that calls `handle()` directly, then two `CommandScenario` specs that run the real pipeline. None of them needs a database, Chronicle, or an HTTP server. ## Set up the lesson Arc for TypeScript is a source preview, so work inside your clone of this repository, where `@cratis/arc.core`, `@cratis/arc.testing`, Vitest, Chai’s `should`, and Sinon are already configured. Create the lesson folder under the Tasks sample, which the repository’s Vitest projects already cover: ```bash mkdir -p Samples/Tasks/Lessons/Shipping/for_QuoteShipping/given ``` The Tasks server only discovers `Samples/Tasks/Features/`, so these files never become routes. Delete the `Lessons` folder when you are done. ## Keep acquiring data separate from deciding Create the command, its value types, and a rule for the weight: Samples/Tasks/Lessons/Shipping/QuoteShipping.ts ```typescript import { ConceptAs, field } from '@cratis/fundamentals'; import { command, ConceptValidator, inject, serviceToken, validator } from '@cratis/arc.core'; export class ParcelWeight extends ConceptAs { static readonly valueType = Number; } export class RatePerKilogram extends ConceptAs { static readonly valueType = Number; } export class ShippingCost extends ConceptAs { static readonly valueType = Number; } export interface RateCard { currentRate(): Promise; } export const rateCard = serviceToken('rateCard'); @command() export class QuoteShipping { @field(ParcelWeight) weight!: ParcelWeight; @inject(rateCard) provide(rates: RateCard): Promise { return rates.currentRate(); } handle(rate: RatePerKilogram): ShippingCost { return new ShippingCost(this.weight.value * rate.value); } } @validator(ParcelWeight) export class ParcelWeightValidator extends ConceptValidator { constructor() { super(); this.ruleFor(weight => weight.value).greaterThan(0).withMessage('Parcel weight must be positive'); } } ``` The weight is in kilograms, and the rate and cost share one currency. They are separate concepts so a weight, a rate, and a cost cannot trade places without the compiler noticing. `provide()` acquires the rate through the `RateCard` service, and its result becomes the first argument of `handle()`. `handle()` only multiplies: the same weight and rate always produce the same cost, with no I/O, clock, or randomness. That makes it a **pure function**. Arc does not require pure handlers; the split pays off when fetching data would otherwise hide the decision. `ParcelWeightValidator` belongs to the value, not to this command, so it runs for every command that carries a `ParcelWeight`. Calling `handle()` yourself runs neither the validator nor `provide()`. ## Specify the decision directly Samples/Tasks/Lessons/Shipping/for_QuoteShipping/when_quoting_directly.ts ```typescript import { ParcelWeight, QuoteShipping, RatePerKilogram, ShippingCost } from '../QuoteShipping.js'; describe('when quoting shipping directly', () => { let cost: ShippingCost; beforeEach(() => { const quote = Object.assign(new QuoteShipping(), { weight: new ParcelWeight(2.5) }); cost = quote.handle(new RatePerKilogram(4)); }); it('should quote ten currency units', () => { cost.value.should.equal(10); }); }); ``` Run it: ```bash yarn vitest run Samples/Tasks/Lessons/Shipping/for_QuoteShipping/when_quoting_directly.ts ``` The spec passes when 2.5 kilograms at 4 per kilogram costs 10. There is no service container, no fake rate card, and no Arc in this test. Add cases here when the calculation grows thresholds, rounding, or tariffs: each one costs a few lines and runs in milliseconds. ## Prove that Arc connects the pieces The direct spec cannot tell you whether Arc still fetches the rate, or whether the weight rule runs. That is the pipeline’s job, so test it through the pipeline. Start with a context both scenario specs share: Samples/Tasks/Lessons/Shipping/for_QuoteShipping/given/a_quote_scenario.ts ```typescript import { CommandScenario } from '@cratis/arc.testing'; import sinon from 'sinon'; import { ParcelWeightValidator, QuoteShipping, rateCard, RatePerKilogram } from '../../QuoteShipping.js'; export class a_quote_scenario { currentRate = sinon.stub().resolves(new RatePerKilogram(4)); scenario = CommandScenario.for(QuoteShipping, ParcelWeightValidator); constructor() { this.scenario.services.addSingleton(rateCard, { currentRate: this.currentRate }); } } ``` `CommandScenario.for` takes the command and the other decorated artifacts it needs; it cannot find a validator you never imported. Register the fake rate card before the first call, because the scenario builds its application lazily on first use. Samples/Tasks/Lessons/Shipping/for_QuoteShipping/when_quoting_through_arc.ts ```typescript import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; import { ParcelWeight } from '../QuoteShipping.js'; import { a_quote_scenario } from './given/a_quote_scenario.js'; describe('when quoting shipping through Arc', given(a_quote_scenario, context => { let result: ScenarioCommandResult; beforeAll(async () => { result = await context.scenario.execute({ weight: new ParcelWeight(2.5) }); }); afterAll(async () => { await context.scenario.dispose(); }); it('should succeed', () => { result.shouldBeSuccessful(); }); it('should acquire the rate once', () => { context.currentRate.callCount.should.equal(1); }); it('should return the calculated cost', () => { (result.response as number).should.equal(10); }); })); ``` This time the boundary is the point. `execute()` encodes the input to the wire shape, runs the concept validator, calls `provide()` with the registered rate card, hands the rate to `handle()`, and encodes the response. The cost comes back as the number `10`, not a `ShippingCost`, because the result is what a client would receive. `given(...)` creates one context for the whole `describe`, so the action runs once in `beforeAll` and the scenario is disposed in `afterAll`. ## Prove rejected input never reaches the rate card Samples/Tasks/Lessons/Shipping/for_QuoteShipping/when_quoting_an_unset_weight.ts ```typescript import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; import { ParcelWeight } from '../QuoteShipping.js'; import { a_quote_scenario } from './given/a_quote_scenario.js'; describe('when quoting an unset weight', given(a_quote_scenario, context => { let result: ScenarioCommandResult; beforeAll(async () => { result = await context.scenario.execute({ weight: new ParcelWeight(0) }); }); afterAll(async () => { await context.scenario.dispose(); }); it('should reject the weight', () => { result.shouldHaveValidationErrorForMember('weight').shouldHaveValidationErrorFor('Parcel weight must be positive'); }); it('should not acquire a rate', () => { context.currentRate.called.should.equal(false); }); })); ``` Run all three specs: ```bash yarn vitest run Samples/Tasks/Lessons/Shipping ``` Six tests pass. They answer three different questions: is the calculation right, does Arc compose the pieces, and does bad input stop before any work. Asserting both the member and the message, and that the rate card was never called, keeps an unrelated failure, such as a missing service, from passing as the expected rejection. ## What you proved, and what you did not You have fast specs for the decision and focused specs for Arc’s composition. You have **not** tested an HTTP route, an authentication handler, or a real rate-card implementation. Test those where they are introduced, rather than adding infrastructure to every arithmetic case: | What you need to prove | Start with | It does not prove | | --- | --- | --- | | A calculation or decision | A direct `handle()` spec with explicit inputs | Validation, authorization, `provide()`, or services | | Validation, authorization, `provide()`, services, and the response | `CommandScenario` | HTTP routing, authentication handlers, or real infrastructure | | Operations executing and compensating in order | `CommandScenario` with fake providers | That a real provider undid anything | | The route, the host, and authentication | `ArcScenario` with HTTP requests; see [Low-level definitions and HTTP](/arc/backend/typescript/testing/low-level-definitions/) | Business edge cases you did not send | ## Next step A handler that performs side effects can return them as [command operations](/arc/backend/typescript/commands/operations/) instead of calling services directly. [Test operations and compensation](/arc/backend/typescript/testing/command-operations/) extends this lesson to prove that Arc runs them, stops after a failure, and undoes the work it started. # Test operations and compensation > Specify a command's declared operations directly, then run real execution, failure, and reverse-order compensation through CommandScenario with a fake provider. A booking test has to answer more than “did the command return a reservation?” You also need to know that Arc calls the provider, stops after a failure, and cancels the reservations it already made. Written by hand, that is a rollback stack in the handler and a pile of mocks in the spec. With [command operations](/arc/backend/typescript/commands/operations/), the handler only declares the work, and Arc runs it and compensates. This lesson shows how to prove each part without a server. You first inspect the command’s decision directly. Then you run the real operation pipeline through `CommandScenario`, including a provider failure halfway through a batch. ## Set up the lesson Work inside your clone, as in [Test a command’s decision](/arc/backend/typescript/testing/command-decisions/), and create a folder the Tasks sample’s Vitest project covers: ```bash mkdir -p Samples/Tasks/Lessons/SeatBooking/for_BookSeat/given \ Samples/Tasks/Lessons/SeatBooking/for_BookSeats/given \ Samples/Tasks/Lessons/SeatBooking/for_ReserveSeat ``` The reservation provider is an application interface. The specs supply a Sinon fake, so nothing leaves the process. ## Declare the operation and the commands Samples/Tasks/Lessons/SeatBooking/SeatBooking.ts ```typescript import { field } from '@cratis/fundamentals'; import { command, CommandOperation, operations, serviceToken, tuple } from '@cratis/arc.core'; export interface SeatReservations { reserve(reservation: string, seat: string, signal: AbortSignal): Promise; cancel(reservation: string, signal: AbortSignal): Promise; } export const seatReservations = serviceToken('seatReservations'); export class ReserveSeat extends CommandOperation { readonly executeDependencies = [seatReservations] as const; readonly compensateDependencies = [seatReservations] as const; constructor(readonly reservation: string, readonly seat: string) { super(); } execute(signal: AbortSignal, reservations: SeatReservations): Promise { return reservations.reserve(this.reservation, this.seat, signal); } compensate(_failure: unknown, signal: AbortSignal, reservations: SeatReservations): Promise { return reservations.cancel(this.reservation, signal); } } @command() export class BookSeat { @field(String) reservation!: string; @field(String) seat!: string; handle() { return tuple(this.reservation, new ReserveSeat(this.reservation, this.seat)); } } export class SeatRequest { @field(String) reservation!: string; @field(String) seat!: string; } @command() export class BookSeats { @field(Array, { genericArguments: [SeatRequest] }) requests!: SeatRequest[]; handle() { return operations(...this.requests.map(request => new ReserveSeat(request.reservation, request.seat))); } } ``` `BookSeat.handle()` returns two values: the reservation ID for the caller, and a `ReserveSeat` operation that Arc runs on the server. `BookSeats` returns an explicit batch of operations. Neither handler calls the provider. `ReserveSeat` names its dependencies with a service token, and Arc resolves them before it starts the first operation. ## Specify the decision without infrastructure Samples/Tasks/Lessons/SeatBooking/for_BookSeat/when_deciding_to_book.ts ```typescript import { BookSeat, ReserveSeat } from '../SeatBooking.js'; describe('when deciding to book a seat', () => { let decision: ReturnType; beforeEach(() => { decision = Object.assign(new BookSeat(), { reservation: 'r-1', seat: 'A-12' }).handle(); }); it('should return the reservation as the response', () => { decision.values[0].should.equal('r-1'); }); it('should declare the requested reservation', () => { decision.values[1].should.be.instanceOf(ReserveSeat); decision.values[1].should.include({ reservation: 'r-1', seat: 'A-12' }); }); }); ``` Calling `handle()` only returns the declaration. No provider exists in this spec, and nothing was reserved: the operation is data you can inspect. Add decision branches here when booking becomes conditional. ## Specify the provider adapter directly The operation’s own `execute()` maps its data to the provider call. Test that mapping on its own: Samples/Tasks/Lessons/SeatBooking/for_ReserveSeat/when_reserving_directly.ts ```typescript import sinon from 'sinon'; import { ReserveSeat, type SeatReservations } from '../SeatBooking.js'; describe('when reserving a seat directly', () => { const signal = new AbortController().signal; let reservations: { reserve: sinon.SinonStub; cancel: sinon.SinonStub }; beforeEach(async () => { reservations = { reserve: sinon.stub().resolves(), cancel: sinon.stub().resolves() }; await new ReserveSeat('r-1', 'A-12').execute(signal, reservations as SeatReservations); }); it('should reserve the requested seat', () => { reservations.reserve.should.have.been.calledOnceWith('r-1', 'A-12', signal); }); it('should not cancel the reservation', () => { reservations.cancel.called.should.equal(false); }); }); ``` This tests your adapter, not Arc’s orchestration. Calling `execute()` yourself never compensates on failure. The next specs cross that boundary on purpose. ## Execute through CommandScenario Samples/Tasks/Lessons/SeatBooking/for_BookSeat/given/a_booking.ts ```typescript import { CommandScenario } from '@cratis/arc.testing'; import sinon from 'sinon'; import { BookSeat, seatReservations } from '../../SeatBooking.js'; export class a_booking { reservations = { reserve: sinon.stub().resolves(), cancel: sinon.stub().resolves() }; scenario = CommandScenario.for(BookSeat); constructor() { this.scenario.services.addSingleton(seatReservations, this.reservations); } } ``` Samples/Tasks/Lessons/SeatBooking/for_BookSeat/when_booking_through_arc.ts ```typescript import { CommandRecoveryStatus } from '@cratis/arc.core'; import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; import { ReserveSeat } from '../SeatBooking.js'; import { a_booking } from './given/a_booking.js'; describe('when booking a seat through Arc', given(a_booking, context => { let result: ScenarioCommandResult; beforeAll(async () => { result = await context.scenario.execute({ reservation: 'r-1', seat: 'A-12' }); }); afterAll(async () => { await context.scenario.dispose(); }); it('should succeed', () => { result.shouldBeSuccessful(); }); it('should call the provider', () => { context.reservations.reserve.should.have.been.calledOnceWith('r-1', 'A-12'); }); it('should observe the completed execution', () => { result.shouldHaveExecutedOperation(ReserveSeat); }); it('should not need recovery', () => { result.recovery!.status.should.equal(CommandRecoveryStatus.NotNeeded); }); it('should return only the caller response', () => { (result.response as string).should.equal('r-1'); }); })); ``` Now Arc does the work. It resolves `seatReservations`, calls `ReserveSeat.execute()`, and sends back only the reservation ID; the operation never reaches the caller. `shouldHaveExecutedOperation` and `result.recovery` report what the pipeline actually observed, not what a stub pretended. `recovery` and `operationOutcomes` exist on results from direct calls and scenarios only; they are never serialized to HTTP. A validation-only call must not touch the provider at all: Samples/Tasks/Lessons/SeatBooking/for_BookSeat/when_validating_a_booking.ts ```typescript import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; import { a_booking } from './given/a_booking.js'; describe('when validating a booking', given(a_booking, context => { let result: ScenarioCommandResult; beforeAll(async () => { result = await context.scenario.validate({ reservation: 'r-1', seat: 'A-12' }); }); afterAll(async () => { await context.scenario.dispose(); }); it('should be valid', () => { result.shouldBeSuccessful(); }); it('should not enter any operation', () => { result.shouldHaveNoOperationInvocations(); }); it('should not call the provider', () => { context.reservations.reserve.called.should.equal(false); }); })); ``` ## Prove compensation after a partial failure Now make the second of three reservations fail. The first two operations enter `execute()`; the third must never start. Arc should cancel the second reservation first, because the provider may have accepted it before reporting the failure, and then the first. Samples/Tasks/Lessons/SeatBooking/for_BookSeats/given/three_seat_requests.ts ```typescript import { CommandScenario } from '@cratis/arc.testing'; import sinon from 'sinon'; import { BookSeats, seatReservations } from '../../SeatBooking.js'; export class three_seat_requests { reservations = { reserve: sinon.stub().resolves(), cancel: sinon.stub().resolves() }; scenario = CommandScenario.for(BookSeats); requests = [ { reservation: 'r-1', seat: 'A-12' }, { reservation: 'r-2', seat: 'A-13' }, { reservation: 'r-3', seat: 'A-14' } ]; constructor() { this.scenario.services.addSingleton(seatReservations, this.reservations); } } ``` Samples/Tasks/Lessons/SeatBooking/for_BookSeats/when_the_second_reservation_fails.ts ```typescript import { CommandRecoveryStatus } from '@cratis/arc.core'; import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; import sinon from 'sinon'; import { ReserveSeat } from '../SeatBooking.js'; import { three_seat_requests } from './given/three_seat_requests.js'; describe('when the second reservation fails', given(three_seat_requests, context => { let result: ScenarioCommandResult; beforeAll(async () => { context.reservations.reserve.withArgs('r-2').rejects(new Error('Reservation provider unavailable')); result = await context.scenario.execute({ requests: context.requests }); }); afterAll(async () => { await context.scenario.dispose(); }); it('should not succeed', () => { result.shouldNotBeSuccessful(); }); it('should keep the original error', () => { result.exceptionMessages.should.include('Error: Reservation provider unavailable'); }); it('should enter only the first two operations', () => { result.recovery!.startedCount.should.equal(2); }); it('should not start the third reservation', () => { context.reservations.reserve.should.not.have.been.calledWith('r-3'); }); it('should observe the first execution completing', () => { result.operationOutcomes![0]!.executionCompleted.should.equal(true); }); it('should observe the second execution failing', () => { result.operationOutcomes![1]!.executionCompleted.should.equal(false); }); it('should compensate in reverse order', () => { sinon.assert.callOrder(context.reservations.cancel.withArgs('r-2'), context.reservations.cancel.withArgs('r-1')); }); it('should report both compensations', () => { result.shouldHaveCompensatedOperation(ReserveSeat); result.recovery!.compensatedCount.should.equal(2); }); it('should report completed recovery', () => { result.recovery!.status.should.equal(CommandRecoveryStatus.Completed); }); })); ``` Each assertion proves something different. A lone `shouldNotBeSuccessful()` would also pass if a service were missing before any operation ran; the provider calls, the skipped third reservation, and the recovery counts rule that out. Neither the command nor the spec contains a rollback stack: Arc tracked what it started and compensated it in reverse. `Completed` means both compensation callbacks returned. A fake cannot prove that a real provider made the cancellation durable, or that a slow reservation will not appear later. Test those guarantees against the provider itself. ## Run the lesson ```bash yarn vitest run Samples/Tasks/Lessons/SeatBooking ``` Twenty-one tests pass across five spec files. Delete `Samples/Tasks/Lessons` when you are done. ## What you proved, and what comes next You have specified the declared decision, the provider adapter, successful composition through Arc, a validation call that touches nothing, and reverse-order recovery after a failure in the middle of a batch. Some cases need more than a fake: * **Cancellation.** Compensation receives its own signal, not the request’s aborted one, with a shared budget set by `commandCompensationTimeoutMs`; see [Implementing operations](/arc/backend/typescript/commands/operations/implementing/). * **Commit facts.** When an execution scope commits business changes, its reported disposition decides whether Arc compensates at all. `shouldHaveIndeterminateRecovery()` asserts the `Unknown` and `Mixed` cases; see the [operations reference](/arc/backend/typescript/commands/operations/reference/). * **The real provider.** Idempotency, key reuse, and delayed creation after a cancellation are the provider’s guarantees. Only integration tests against it can establish them. Return to [Testing](/arc/backend/typescript/testing/) to choose the next boundary, or read [Testing commands](/arc/backend/typescript/testing/commands/) for every scenario assertion. # Testing commands > Run a decorated command through the real pipeline with CommandScenario, register fakes, set a trusted context, and assert results, validation, and operations. Use `CommandScenario` when the command’s authorization, validators, service resolution, or handler behavior matters to the spec. It runs the command through the same pipeline as HTTP, without starting a server. ## Execute a command The [Tasks sample spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/for_RegisterTask/when_registering/with_valid_title.ts) registers a task and checks both the result and the service: ```typescript import { given, type ScenarioCommandResult } from '@cratis/arc.testing'; import { TaskId } from '../../../TaskId.js'; import { TaskTitle } from '../../../TaskTitle.js'; import { a_task_registration } from '../given/a_task_registration.js'; describe('when registering a task with a valid title', given(a_task_registration, context => { const id = TaskId.create(); let result: ScenarioCommandResult; beforeAll(async () => { result = await context.scenario.execute({ id, title: new TaskTitle('Plan release') }); }); afterAll(async () => { await context.scenario.dispose(); }); it('should succeed through the command pipeline', () => { result.shouldBeSuccessful(); }); it('should register the task with the service', () => { String(context.tasks.byId(id)?.title.value).should.equal('Plan release'); }); })); ``` The context class `a_task_registration` is shown in [Testing](/arc/backend/typescript/testing/#share-a-context-with-given). * `CommandScenario.for(Command, ...artifacts)` takes the command and any decorated validators or other artifacts it needs. It cannot discover classes that were never imported. * `execute(values)` accepts property values or a command instance. `validate(values)` runs authorization and validation but never calls `provide()` or `handle()`. * Register fakes on `scenario.services` before the first call: `addSingleton(Token, instance)` keeps a caller-owned instance; `addScoped(Token, factory)` and `addTransient(Token, factory)` register application-owned factories. ## Set a trusted context `scenario.withContext({ principal, tenantId, correlationId, signal })` sets the identity a trusted direct caller would pass. `withAllowedValidationSeverity(Severity.Error)` lets error-severity results pass, which only trusted callers can do; see [Validation severity filtering](/arc/backend/typescript/commands/validation-severity-filtering/). ## Test authorization A decorator moved to the wrong class, or a new command without one, leaves an operation open, and no other spec notices. Specify who may call as deliberately as what the command does. This command requires the `Planner` role: ArchiveTask.ts ```typescript import { field } from '@cratis/fundamentals'; import { command, roles } from '@cratis/arc.core'; export const archived: string[] = []; @command() @roles('Planner') export class ArchiveTask { @field(String) id!: string; handle(): void { archived.push(this.id); } } ``` Cover the three callers that matter: nobody, somebody without the role, and somebody with it: for_ArchiveTask/when_archiving.ts ```typescript import { CommandScenario } from '@cratis/arc.testing'; import { ArchiveTask } from '../ArchiveTask.js'; const planner = { id: 'ada', roles: ['Planner'], isAuthenticated: true }; const viewer = { id: 'grace', roles: ['Viewer'], isAuthenticated: true }; describe('when archiving a task', () => { let scenario: CommandScenario; beforeEach(() => { scenario = CommandScenario.for(ArchiveTask); }); afterEach(async () => { await scenario.dispose(); }); it('should deny an anonymous caller', async () => { (await scenario.execute({ id: 't-1' })).shouldNotBeAuthorized(); }); it('should deny a caller without the role', async () => { (await scenario.withContext({ principal: viewer }).execute({ id: 't-1' })).shouldNotBeAuthorized(); }); it('should allow a planner', async () => { (await scenario.withContext({ principal: planner }).execute({ id: 't-1' })).shouldBeSuccessful(); }); it('should deny on the validation route too', async () => { (await scenario.withContext({ principal: viewer }).validate({ id: 't-1' })).shouldNotBeAuthorized(); }); }); ``` The principal you pass is what an authentication handler would have produced: an `id`, `roles`, and `isAuthenticated`. The scenario runs Arc’s real authorization stage, so the specs fail if the decorator disappears. They do not exercise your authentication handler or the HTTP status code; test those through [`ArcScenario`](/arc/backend/typescript/testing/low-level-definitions/) requests. A decision that needs loaded data, such as “only the owner may rename”, returns `denied(reason)` from `provide()`. The same assertion covers it, and the reason is on the result: `result.shouldNotBeAuthorized()` and `result.authorizationFailureReason.should.equal('Only the owner can rename a task')`. For queries, `QueryScenario` and `ObservableQueryScenario` take the same `withContext({ principal })`, and a denied query reports `isAuthorized: false`, on the result for `QueryScenario` and on `rejection` for `ObservableQueryScenario`. ## Assertions The result is the actual `CommandResult` with chainable assertions: | Assertion | Passes when | | --- | --- | | `shouldBeSuccessful()` / `shouldNotBeSuccessful()` | `isSuccess` is true / false | | `shouldBeValid()` | No blocking validation result | | `shouldHaveValidationErrors()` | An authored rule failed | | `shouldHaveValidationErrorFor(text)` | A result’s message **contains** this text (case-sensitive) | | `shouldHaveValidationErrorForMember(member)` | A result concerns this member | | `shouldHaveValidationErrorBecauseOf(reason)` | A result has this reason, such as `validatorFailed` | | `shouldBeAuthorized()` / `shouldNotBeAuthorized()` | `isAuthorized` is true / false | | `shouldHaveExceptions()` / `shouldNotHaveExceptions()` | `hasExceptions` is true / false | Assert the field with `shouldHaveValidationErrorForMember('title')`, not `shouldHaveValidationErrorFor('title')`: the second passes for any message that happens to contain the word. Pass the full message to `shouldHaveValidationErrorFor` when the wording matters. A dependency or validator construction failure alone cannot satisfy the generic or member validation assertions: it means no authored rule was established. Unlike .NET, `validatorFailed` alone does not satisfy `shouldHaveValidationErrors()`; assert it explicitly with `shouldHaveValidationErrorBecauseOf('validatorFailed')`. ## Assert operations | Assertion | Passes when | | --- | --- | | `shouldHaveExecutedOperation(Type)` | An operation of that type finished `execute` | | `shouldHaveCompensatedOperation(Type)` | An operation of that type finished `compensate` | | `shouldHaveNoOperationInvocations()` | No operation was entered, not even partially | | `shouldHaveIndeterminateRecovery()` | The recovery status is `Indeterminate` | These inspect real pipeline observations, not simulated work. [Test operations and compensation](/arc/backend/typescript/testing/command-operations/) walks through a full example, including a failure in the middle of a batch. See also [Command operations](/arc/backend/typescript/commands/operations/). ## Related * [Testing queries](/arc/backend/typescript/testing/queries/) * [Command validation](/arc/backend/typescript/commands/command-validation/) # Testing low-level definitions and HTTP > Exercise defineCommand and defineQuery definitions, and full HTTP requests, through a real ArcServer with ArcScenario and shouldHaveRuleFailure. `ArcScenario` accepts the same registrations and definitions as `ArcServer`, plus default context fields and per-call overrides, and delegates to a real server. Use it for low-level definitions and for specs that need the full HTTP pipeline. ```typescript import { ArcScenario, shouldHaveRuleFailure } from '@cratis/arc.testing'; import { defineCommand, Severity, validation } from '@cratis/arc.core'; import { z } from 'zod'; const scenario = new ArcScenario({ commands: [defineCommand({ name: 'Create', schema: z.object({ title: z.string() }), validate: ({ title }) => title ? [] : [validation('Title required', ['title'], 'rule', Severity.Error)], handle: () => ({ created: true }) })] }); try { const result = await scenario.executeCommand('Create', { title: '' }); shouldHaveRuleFailure(result, { reason: 'rule', member: 'title', severity: Severity.Error }); console.log(result.isSuccess); // false } finally { await scenario.dispose(); } ``` ## Entry points | Method | Runs | | --- | --- | | `executeCommand(name, input, context?)` | The direct command pipeline; `context` overrides the scenario’s default context fields | | `validateCommand(name, input, context?)` | Authorization and validation without running the command handler | | `performQuery(name, input, context?, options?)` | The direct query pipeline, with optional paging and sorting | | `handle(request)` | The full HTTP pipeline, authenticating with the configured handlers | For direct calls, supply a trusted principal and tenant through the scenario’s context overrides. HTTP `handle` authenticates from its configured handlers instead. `shouldHaveRuleFailure(result, { reason, member?, severity?, correlationId? })` checks a rule failure, and throws when the only failure is `dependencyUnavailable` or `validatorFailed`. To check cleanup and handler invocation, record calls in your factory or callback and assert them after the call completes. After your assertions, call `dispose()`; if you passed an existing `ServiceRegistry`, dispose that registry yourself. ## Related * [Low-level definitions](/arc/backend/typescript/commands/low-level-definitions/) * [Testing](/arc/backend/typescript/testing/) # Run a CommandScenario with node:test > Test a decorated command with Node.js's built-in test runner and dispose its scenario after the test. You do not need a runner-specific Arc adapter to exercise a command pipeline. `CommandScenario` is runner-neutral: create it once, execute it in a test, and dispose it with `after` when the test finishes. This example needs a built clone with `@cratis/arc.core`, `@cratis/arc.testing`, and `@cratis/fundamentals` available from the workspace. ## Run the scenario when_archiving.test.ts ```typescript import { test, after } from 'node:test'; import { command } from '@cratis/arc.core'; import { field } from '@cratis/fundamentals'; import { CommandScenario } from '@cratis/arc.testing'; const archived: string[] = []; @command() class ArchiveTask { @field(String) id!: string; handle(): void { archived.push(this.id); } } const scenario = CommandScenario.for(ArchiveTask); after(async () => { await scenario.dispose(); }); test('when archiving a task, the command pipeline executes its handler', async () => { const result = await scenario.execute({ id: 't-1' }); result.shouldBeSuccessful(); if (archived.length !== 1 || archived[0] !== 't-1') throw Error('Task was not archived'); }); ``` Compile this TypeScript using the standard decorator configuration described in [Decorators](/arc/backend/typescript/decorators/), then run `node --test when_archiving.test.js`. Inside this clone, `yarn check:node-test` executes the same scenario from [`scripts/check-node-test.test.mjs`](https://github.com/Cratis/Arc.TypeScript/blob/main/scripts/check-node-test.test.mjs), with the decorator calls written explicitly for native Node.js execution. It verifies one passing test and uses `after` to dispose the application. It does not start an HTTP server or check transport behavior; use [HTTP scenarios](/arc/backend/typescript/testing/low-level-definitions/) for that boundary. For runner-neutral scenario assertions and service registration, continue with [Testing commands](/arc/backend/typescript/testing/commands/). # Testing observable queries > Open a decorated observable query through the real pipeline with ObservableQueryScenario, collect emissions under one deadline, and tell completion from rejection. `ObservableQueryScenario` opens a live query through the real observable pipeline and collects what it emits, within a deadline you set. ## Collect emissions The [Tasks sample spec](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Listing/for_TaskItem/when_collecting/with_current_value.ts) registers a task and collects the current value: ```typescript import { given } from '@cratis/arc.testing'; import { TaskId } from '../../../TaskId.js'; import { TaskTitle } from '../../../TaskTitle.js'; import { a_task_listing } from '../given/a_task_listing.js'; describe('when collecting the current tasks emission', given(a_task_listing, context => { let result: Awaited>; beforeEach(async () => { context.tasks.register(TaskId.create(), new TaskTitle('Review changes')); result = await context.observable.collect(1, 1_000); }); afterAll(async () => { await context.query.dispose(); await context.observable.dispose(); }); it('should receive the wire-shaped current value', () => { result.emissions.should.have.lengthOf(1); String(result.emissions[0]?.data?.[0]?.title).should.equal('Review changes'); }); })); ``` The `a_task_listing` context is shown on [Testing queries](/arc/backend/typescript/testing/queries/#perform-a-query-with-paging-and-sorting). ## collect() `collect(count, timeoutMs = 5000, arguments = {}, options?)` waits for up to `count` emissions, or fails at the deadline, which also covers opening the subscription. The scenario closes its subscription in every case, including a timeout. | Result field | Meaning | | --- | --- | | `emissions` | The query results received, wire-shaped | | `completed` | `true` only when a finite stream completed before reaching `count` | | `rejection` | The query result when opening was rejected, such as a failed authorization or validation | A rejection is reported in `rejection` with `completed: false`, not as an emission. Always assert the expected emission count. ## Related * [Observable queries](/arc/backend/typescript/queries/observable-queries/) * [Testing](/arc/backend/typescript/testing/) # Testing queries > Run a decorated static query through the real pipeline with QueryScenario, pass arguments, paging, and sorting, and assert the wire-shaped result. `QueryScenario` selects one decorated `@query` method on a read model and performs it through the real query pipeline. ## Perform a query with paging and sorting The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Listing/for_TaskItem/when_performing/with_sorting_and_paging.ts) registers two tasks and asks for the first page, sorted by title: ```typescript import { SortDirection } from '@cratis/arc.core'; import { given } from '@cratis/arc.testing'; import { TaskId } from '../../../TaskId.js'; import { TaskTitle } from '../../../TaskTitle.js'; import { a_task_listing } from '../given/a_task_listing.js'; describe('when performing the tasks query with sorting and paging', given(a_task_listing, context => { let result: Awaited>; beforeAll(() => { context.tasks.register(TaskId.create(), new TaskTitle('Zebra')); context.tasks.register(TaskId.create(), new TaskTitle('Apple')); }); beforeEach(async () => { result = await context.query.perform({}, { sorting: { field: 'title', direction: SortDirection.Ascending }, paging: { page: 0, pageSize: 1 } }); }); afterAll(async () => { await context.query.dispose(); await context.observable.dispose(); }); it('should return the first sorted wire value', () => { result.isSuccess.should.equal(true); String(result.data?.[0]?.title).should.equal('Apple'); }); it('should report the total number of items', () => { result.paging.totalItems.should.equal(2); }); })); ``` Its context creates the scenarios: Features/Tasks/Listing/for_TaskItem/given/a_task_listing.ts ```typescript import { ObservableQueryScenario, QueryScenario } from '@cratis/arc.testing'; import { Tasks } from '../../../Tasks.js'; import { TaskItem } from '../../Listing.js'; import { metadata } from '../../../../generatedMetadata.js'; export class a_task_listing { tasks = new Tasks(); query = QueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'allTasks'); observable = ObservableQueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'observeAllTasks'); constructor() { this.query.extend(builder => builder.useGeneratedMetadata(metadata)); this.observable.extend(builder => builder.useGeneratedMetadata(metadata)); this.query.services.addSingleton(Tasks, this.tasks); this.observable.services.addSingleton(Tasks, this.tasks); } } ``` ## How it works * `QueryScenario.for(ReadModel, 'methodName', ...artifacts)` selects the query. `T` describes the **wire** data, not a rehydrated read-model instance: `data` is JSON-shaped, so concepts arrive as strings. * `perform(arguments, options?)` takes named arguments, such as `{ id: TaskId.create() }`, and optional `{ paging, sorting }`. * The result is the actual `QueryResult`. Queries always use `Warning` as the allowed severity. * `withContext({ principal, tenantId })` sets a trusted identity, as for commands. ## Query Chronicle read models in memory `ChronicleQueryScenario` runs a snapshot query through the same Arc query pipeline, with tenant-scoped in-memory Chronicle state. A query can inject `ChronicleReadModels` and call `getById` or `findInstanceById` to read a seeded source. Register the query’s event, reducer, and read-model types as artifacts: Features/Accounts/for_AccountBalance/when_querying/with_seeded_history.ts ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { reducer } from '@cratis/chronicle/reducers'; import { readModel as chronicleReadModel } from '@cratis/chronicle/readModels'; import { argument, query, readModel, service } from '@cratis/arc.core'; import { ChronicleReadModels } from '@cratis/arc.chronicle'; import { ChronicleQueryScenario } from '@cratis/arc.chronicle/testing'; @eventType() class BalanceChanged { @field(Number) amount: number; constructor(amount = 0) { this.amount = amount; } } @chronicleReadModel() class AccountBalance { @field(Number) amount = 0; } @reducer('account-balance-query-reducer', undefined, AccountBalance) class BalanceReducer { balanceChanged(event: BalanceChanged, current?: AccountBalance): AccountBalance { return { amount: (current?.amount ?? 0) + event.amount }; } } @readModel() class AccountQueries { @query(argument('id', String), service(ChronicleReadModels)) static byId(id: string, models: ChronicleReadModels): Promise { return models.getById(AccountBalance, id); } } const scenario = ChronicleQueryScenario.for<{ amount: number }>(AccountQueries, 'byId', AccountBalance, BalanceChanged, BalanceReducer); scenario.given.forEventSource('account-1').events(new BalanceChanged(25)); const result = await scenario.perform({ id: 'account-1' }); result.data!.amount.should.equal(25); await scenario.dispose(); ``` `given.forEventSource(id, tenant?).readModel(instance)` pins a value for that source, read-model type, and tenant; pins win over reducer or projection history. `givenReadModel(Type, id, instance, tenant?)` can also pin by explicit type. With no pin and no seeded history for that source, or no projection or reducer candidates for that model, a keyed lookup returns `null`. The tenant defaults to the current trusted `scenario.context.tenantId` (or `Default`) **when** `.events()` or `.readModel()` is called; `withContext({ tenantId })` selects the tenant used by the query. Only keyed lookups are backed in memory. `getAll` and store watches reject with a kernel hint. Declared observable queries cannot be performed by `ChronicleQueryScenario`: observation needs `ChronicleKernelScenario` because `ObservableQueryScenario` has no in-memory Chronicle wiring. Reducer seeding needs `@cratis/chronicle` 6.14.0 or later. With SDK 6.19.0 or later, the scenario evaluates [supported flat projections in-process](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/testing.md#projection-capabilities) from seeded events. On older SDKs, a seeded lookup with a registered projection that omits its read-model type fails explicitly with `@cratis/chronicle >= 6.19.0` and suggests `@projection('', ReadModel)` because Arc cannot determine the association without the evaluator. Unsupported definitions throw the SDK’s `UnsupportedProjectionOperation`; use [ChronicleKernelScenario](/arc/backend/typescript/testing/chronicle-kernel/) for those, for observation, or for observer-driven updates after a command append. See [Testing Chronicle commands](/arc/backend/typescript/testing/chronicle/) for the same seeding rules. ## Snapshot versus streaming `QueryScenario.perform()` rejects decorated `@query({ observable: true })` methods instead of returning a current snapshot as `ArcServer.performQuery()` does. For non-Chronicle observable queries, use [ObservableQueryScenario](/arc/backend/typescript/testing/observable-queries/). `ChronicleQueryScenario.perform()` also rejects declared observable queries and directs you to `ChronicleKernelScenario` for observation. A method declared as a snapshot that unexpectedly returns a subscribable or async iterable is rejected by the core query pipeline. Whether called over HTTP or through `QueryScenario`, Arc attempts release through the first hook on the returned object, in order: `Symbol.asyncDispose`, `Symbol.dispose`, `dispose()`, `close()`, or `return()` if the object itself has both `next()` and `return()`. Arc never releases an instance owned by its service container; its scope or registry handles disposal. It never subscribes or calls `[Symbol.asyncIterator]()` or `[Symbol.iterator]()` during cleanup; a cold RxJS Observable or shared Subject with no release hook is rejected but left untouched. Cleanup has its own deadline even when the request is canceled. A release failure (including a timeout) is reported alongside the snapshot rejection; a failure after the deadline is logged. Custom scenario harnesses can use `snapshotStreamSource(result)` from `@cratis/arc.core` to inspect the rejected source; Arc has already attempted release if the source owns a hook, so do not release it a second time. ## Related * [Testing observable queries](/arc/backend/typescript/testing/observable-queries/) * [Paging and sorting](/arc/backend/typescript/queries/model-bound/paging/) # Troubleshooting > Fix the common Arc for TypeScript problems with installation, decorators and compilers, generated metadata, module resolution, discovery, host adapters, authentication, and generated clients. Each entry names the symptom, the usual cause, and the fix, with a link to the page that has the full contract. ## Installation ### Installing Arc outside the clone fails `npm install @cratis/arc.core` fails with `404 Not Found`, or installing a tarball fails with `Unsupported URL Type "workspace:": workspace:^`. The Arc packages are not published to npm, and a `workspace:^` dependency resolves only inside this repository’s Yarn workspace. Build a clone, pack the packages you need with `yarn workspace pack`, and install the tarballs. Do not use `npm pack`: it leaves the internal `workspace:^` dependencies in the packed manifest, and the tarball fails with the second error. See [Create an application](/arc/backend/typescript/getting-started/create-an-application/). ### Corepack is missing `corepack enable` fails with `command not found`. Node.js 25 and later no longer include Corepack. Install it with `npm install --global corepack`, then run `corepack enable` again. The repository pins its Yarn version in `package.json`, and Corepack selects that version. ## Decorators and compilers ### TS1241: Unable to resolve signature of method decorator In standard decorator mode, `@inject(...)` and `@query(...)` type-check the method’s parameters against the tokens and descriptors you list. The error almost always means they disagree: a missing or extra token, the wrong order, or a `handle()` that forgot the `provide()` result as its **first** parameter. Make the list match the signature one to one. See [Dependency injection](/arc/backend/typescript/dependency-injection/#decorator-modes). ### “SyntaxError: Invalid or unexpected token” at a decorator when running .ts directly Node.js type stripping removes types but does not transform decorators, so `node file.ts` fails at the first `@`. Compile first with `tsc` (the sample’s `yarn build`) or with esbuild, which lowers standard decorators for `target: "es2022"`, and run the emitted JavaScript. During development, run the source with `tsx watch`, as [Create an application](/arc/backend/typescript/getting-started/create-an-application/#run-the-development-loop) does. ### Decorators do nothing, or metadata is missing, in Vitest or Vite The test transformer must lower standard decorators with `useDefineForClassFields: true`. This repository adds an esbuild pre-transform plugin for `.ts` files in its [`vite.base.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/vite.base.ts), with `target: 'es2022'` and `experimentalDecorators: false`. Use the same approach in your own Vitest configuration. ### build() fails with “Unbound handle parameters” or “Missing parameter metadata” This is the usual first-run failure of a new application. TypeScript erases parameter types at runtime, so without help Arc cannot tell that `handle(tasks: Tasks)` needs the `Tasks` service. `build()` stops with one of these messages: | Message | Cause | | --- | --- | | `Unbound handle parameters on .handle; use builder.useGeneratedMetadata(metadata) or @inject(...); default and rest parameters require explicit binding` | A command’s `handle()` has parameters and no tokens | | `Unbound provide parameters on .provide; use builder.useGeneratedMetadata(metadata) or @inject(...); default and rest parameters require explicit binding` | The same for `provide()` | | `Missing parameter metadata for .; use explicit tokens` | A bare `@query()` or an empty `@inject()` on a method with parameters, in standard decorator mode | | `Unbound parameters on .` | The `@query(...)` descriptors do not cover every parameter of the method | Fix it one of two ways: * Generate artifact metadata with `arc-proxygenerator --metadata ` and call `builder.useGeneratedMetadata(metadata)` before `discover()` or `add()`, as the Tasks sample does. See [Generate artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/). * List the tokens yourself: `@inject(Tasks)` on `handle()`, and `@query(argument('id', TaskId), service(Tasks))` in parameter order on a query. Default and rest parameters always need explicit tokens. In legacy `experimentalDecorators` mode with `emitDecoratorMetadata`, an empty `@inject()` or a bare `@query()` infers class-valued parameters from `design:paramtypes`; a parameter it cannot infer fails with `Unresolvable parameter on .; use explicit tokens`. See [Dependency injection](/arc/backend/typescript/dependency-injection/#decorator-modes). ### The server stops with stale generated artifact metadata `builder.useGeneratedMetadata(metadata)` throws `Stale generated artifact metadata for ; regenerate artifact metadata`, or `arc-proxygenerator --check-metadata` fails with `Stale or missing generated artifact metadata: ; regenerate artifact metadata`. An artifact changed after the metadata module was generated, for example a field or the number of `handle()` parameters. A query method added since the last run fails as `Missing parameter metadata for .` instead, described above. Run the generator again with the same options. While you develop, keep it running with `--watch` beside your `tsx` process; `tsx` may restart once with the old module before the generator finishes. Reordering parameters without changing their count goes undetected, so regenerate after every artifact edit and gate CI with `--check-metadata`. See [Generate artifact metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/). ## Module resolution ### NodeNext or Bundler? Server code that imports `@cratis/arc.core` and `@cratis/fundamentals` 7.22.0 type-checks with `module` and `moduleResolution` set to `NodeNext`, or with `ESNext` and `Bundler` as the Tasks sample does. With NodeNext and native ESM, keep `.js` extensions on relative imports. Generated frontend proxies are different: the published `@cratis/arc` client declarations use extensionless imports, so compile proxies in `Bundler` mode. `NodeNext` consumer compilation of generated proxies is not supported. See [Proxy generation](/arc/backend/typescript/proxy-generation/#compatibility). ## Discovery ### discover() refuses the folder `discover()` refuses a folder that contains the entry point or an imported bootstrap that is itself calling discovery, because importing it would re-enter a suspended top-level `await`. Put artifacts in a dedicated folder such as `Features/`. It also rejects a folder that mixes emitted `.js` and loader-backed `.ts` files. ### An artifact is not found `discover()` only registers **exported** classes, and skips `dist`, `node_modules`, `given`, `for_*`, `index.*`, declaration files, and symbolic links. A validator that is never imported is never registered: add it with `builder.add(...)` or keep it under the discovery root. ### Routes changed after a refactor, or are wrong in a bundled build Routes derive from folders and class names. A folder move changes the route, and a bundler that renames classes changes it too. Pin routes with `@command({ namespace })`, `@readModel({ namespace })`, or `@path(...)`, and keep class names (`keepNames` in esbuild). See [Endpoint mapping](/arc/backend/typescript/core/endpoint-mapping/). ### “Not an Arc artifact” `builder.add(...)` received a class without an Arc decorator. Check that the class has `@command()`, `@readModel()`, `@validator(...)`, a lifetime decorator, or one of the extension-point decorators. ## Hosting ### Every command answers 400 malformedRequest behind Express A body parser such as `express.json()` ran before Arc and consumed the body. Call `app.use(cratisArc(arc))` before adding body parsers. See [Express](/arc/backend/typescript/hosts/express/). ### Fastify answers 413 for a large body Fastify’s `bodyLimit`, 1 MiB by default, applies before Arc’s `hosting.maxBodyBytes`. Raise both. See [Fastify](/arc/backend/typescript/hosts/fastify/). ### A browser WebSocket never opens from a dev server The page’s origin differs from the server’s, so the `Origin` check refuses the upgrade. Add the dev server origin, and your application origin, to `query.allowedOrigins`. See [WebSockets](/arc/backend/typescript/hosts/websockets/#origin-checks). ### An observable query answers 202 The source has no current value yet. Use RxJS `new BehaviorSubject(value)` for a current value, or ask with `waitForFirstResult=true`. An emission guard that suppresses the snapshot also answers 202. See [Using observable queries with curl](/arc/backend/typescript/queries/using-observable-queries-with-curl/). ## Security ### A protected operation answers 403 where you expected 401 No authentication handler is configured, so nobody is authenticated and the declaration fails at the authorization step. Configure `authentication`, or a [native principal](/arc/backend/typescript/hosts/native-principal/). ### The server refuses to start with native principal and handlers `nativePrincipal: true` and `authentication` handlers are mutually exclusive. Choose one boundary. See [Authentication](/arc/backend/typescript/core/authentication/#choose-an-authentication-boundary). ## Generated clients ### The generated client calls a route that answers 404 The generator’s route options do not match the server’s `generatedApis` settings or discovery root namespace. Align `--api-prefix`, `--segments-to-skip`, `--root-namespace`, and the name-in-route switches. See [Proxy generator configuration](/arc/backend/typescript/proxy-generation/configuration/#route-alignment). ### “Ambiguous model name” The proxy generator fails with `Ambiguous model name: .` when two different model declarations have the same namespace and class name. The generator derives a model’s namespace from its folder below the artifacts root, so this happens when two files in one folder declare the same model name. Models with the same class name in different folders are supported. Rename one of the two, or move it to another folder. ## Chronicle ### Commands or queries fail after registering Chronicle late `withChronicle` can run before or after `discover()`: Arc replays discovered artifact types to Chronicle when you register it. If routes still fail, check that the relevant event types and read models are exported beneath the discovery root. When using `add()` for Chronicle-only artifacts, call `withChronicle` first so `add()` recognizes them. See [Add event sourcing](/arc/backend/typescript/chronicle/add-event-sourcing/). ### A command or query that uses Chronicle never answers The Chronicle kernel is not running, or not reachable at the connection string’s address. The server starts without it, and the SDK keeps trying to connect, so the request waits instead of failing. It completes once the kernel is up. Check the kernel’s health; see [Start a development kernel](/arc/backend/typescript/chronicle/add-event-sourcing/#start-a-development-kernel). ### Chronicle commands and queries answer 500 with “An unexpected error occurred” Check that event types and projections are exported beneath the discovery root. If `add(...)` registers Chronicle-only artifacts, call `withChronicle` first so Arc recognizes them. See [Registration options](/arc/backend/typescript/chronicle/registration-options/). ### Every command waits for the full completion timeout, then answers 500 The application sets `completionTimeoutMs`, and it has a projection or reactor that does not handle the appended event type. Chronicle waits for every observer on the event log, so the wait times out although the events were appended. This is [Cratis/Chronicle#4132](https://github.com/Cratis/Chronicle/issues/4132). Remove `completionTimeoutMs`, or see [Make the command wait](/arc/backend/typescript/chronicle/add-event-sourcing/#make-the-command-wait). A kernel test scenario behaves the same way; see [Register only the observers the scenario needs](/arc/backend/typescript/testing/chronicle-kernel/#register-only-the-observers-the-scenario-needs). ### Do I need to import reflect-metadata? Not for the Chronicle SDK 6.7. It depends on `reflect-metadata`, and each of its modules that reads decorator metadata imports it. Importing it first in your entry point is harmless. A browser frontend that uses the Arc client packages still imports it; see [Set up proxy generation](/arc/backend/typescript/proxy-generation/getting-started/). ## Related * [Capability reference](/arc/backend/typescript/reference/capabilities/) for what is and is not implemented * [Code analysis](/arc/backend/typescript/code-analysis/) to catch many of these in the editor # Migrating to secure discovery defaults > Restore access for discovery clients after the change to authenticated discovery outside Development, or explicitly keep anonymous discovery. ## Who is affected Applications and tools that anonymously read a deployed host’s command/query catalogs, identity schema, user/tenant discovery, or `/openapi.json`. These endpoints no longer expose descriptions anonymously outside Development by default. This is a breaking change; command/query invocation and `/.cratis/me` are unchanged. ## What changed In Development, discovery stays anonymous. Everywhere else, configured Arc authentication handlers or a host-verified native principal must authenticate the caller. Anonymous requests receive 401. A host with no authentication leaves the discovery endpoints unmapped (normally 404) and logs one startup warning. Explicitly requiring authentication without configuring it fails startup instead. Development means the code-only option `environmentName: 'Development'` (case-insensitive), which overrides the environment variables, or, when absent, the first defined value of `DOTNET_ENVIRONMENT`, `ASPNETCORE_ENVIRONMENT`, and `NODE_ENV`. There is no `Cratis:Arc:EnvironmentName` configuration key. No environment means non-Development. The Node builder honors `configuration.env`; fetch runtimes without `process` can set `environmentName` explicitly. `development: true` only enables fixture providers and does not bypass this policy. The discovery-only `environmentName` override does not change exception exposure: `exposeExceptionDetails` still defaults from the host environment, including a supplied Node `configuration.env`, and can be set explicitly. ## Restore access Prefer authenticating deployed tools using your existing handlers. Run a local build-time HTTP description consumer against a Development host. Source-based proxy generation needs no running HTTP host and is unchanged. If deployed tools do not need catalogs or HTTP OpenAPI, an alternative is to set `Cratis__Arc__Introspection__Enabled=false` in the deployment, or `Cratis:Arc:Introspection:Enabled` to `false` in `appsettings.Production.json`. The catalogs and `/openapi.json` then stay unmapped even with authentication configured. Local discovery can stay enabled. Identity discovery keeps its access policy and startup authentication diagnostics; in-process `openApi()` and `exportClientManifest` are unaffected. See [Turn discovery off](/arc/backend/typescript/introspection/#turn-discovery-off). If you deliberately want the old anonymous behavior, set the option in the Node builder (or pass the same option to `ArcServer` or the fetch builder): ```typescript import { ArcApplication } from '@cratis/arc.core'; const builder = ArcApplication.createBuilder({ introspection: { requireAuthentication: false } }); ``` For Node configuration, use `Cratis__Arc__Introspection__RequireAuthentication=false`, or: ```json { "Cratis": { "Arc": { "Introspection": { "RequireAuthentication": false } } } } ``` An anonymous opt-out outside Development logs a warning. To narrow authenticated discovery to operators, use `introspection: { roles: 'Admin,Operator' }` or `Cratis__Arc__Introspection__Roles=Admin,Operator`. Any listed role grants access; roles imply authentication even in Development. Empty roles and roles combined with an anonymous opt-out are startup errors. See [Introspection](/arc/backend/typescript/introspection/#production-access) for the complete endpoint list, configuration precedence, and differences from .NET hosting. # Keep a behavior together in a vertical slice > Keep backend artifacts, generated client proxies, and React components together in a vertical-slice folder. When you change how an author registers, you should not have to hunt through separate command, validator, and event folders. Keep the behavior in a slice folder, with one TypeScript file named for that behavior: ```text Features/Authors/ ├── AuthorId.ts ├── AuthorName.ts ├── Registration/ │ ├── Registration.ts │ ├── RegisterAuthor.proxy.ts │ ├── RegisterAuthorForm.tsx │ ├── for_RegisterAuthor/when_registering/with_librarian_role.ts │ └── for_RegisterAuthorForm/when_submitting/with_a_valid_name.tsx └── Listing/ ├── Listing.ts ├── for_Author/when_paging/with_multiple_authors.ts ├── AllAuthors.proxy.ts ├── Author.proxy.ts ├── AuthorsPage.proxy.ts ├── AuthorCatalog.tsx └── for_AuthorCatalog/when_showing_a_page/with_authors.tsx ``` [`Registration.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Registration/Registration.ts) exports `RegisterAuthor`, `AuthorRegistered`, and the `UniqueAuthorName` Chronicle constraint. The concept and its validator live together in `AuthorName.ts`. [`Listing.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Listing/Listing.ts) exports the `Author` read model with its snapshot and observable queries. `AuthorId` and `AuthorName` live one level up because other slices use them. The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts) follows the same layout. This is an **application convention**, not a requirement of the Arc runtime. The [C# Library registration slice](https://github.com/Cratis/Samples/blob/main/Library/Lending/Authors/Registration/Registration.cs) puts `RegisterAuthor`, `AuthorRegistered`, and `UniqueAuthorName` in `Registration.cs`; [Studio’s registration slice](https://github.com/Cratis/Studio/blob/main/Source/Core/Projects/Registration/Registration.cs) also keeps its command, constraint, and event together. In a Chronicle TypeScript slice, place its event alongside the command, give it a constructor and `@field` declarations, and return an event instance from `handle()` as Library does. Arc without Chronicle needs no event; Tasks demonstrates that path. Discover the **folder**, not a single class: `await builder.discover(new URL('./Features/', import.meta.url))` registers every exported, decorated artifact in each backend module. Discovery ignores `*.proxy.ts`, `*.proxy.js`, and React `.tsx` files. The [proxy generator](/arc/backend/typescript/proxy-generation/) reads every exported command and read-model declaration in a file and writes one browser proxy **per operation**, next to the backend file it mirrors. It also records each exported validator in [generated metadata](/arc/backend/typescript/proxy-generation/generated-artifact-metadata/). The [Arc ESLint rules](/arc/backend/typescript/code-analysis/) check backend artifacts but exclude generated proxies. Give components names for what they render (`RegisterAuthorForm.tsx`), not the same basename as a backend `.ts` module; TypeScript silently drops one of those two files from a compilation. After you add or change a slice, regenerate the proxies and metadata with `arc-proxygenerator --artifacts Features --output Features --use-proxy-file-suffix` (use absolute paths in a script). Co-located generation skips barrels automatically. Exclude `**/*.proxy.ts`, `**/*.tsx`, and `**/for_*/**` from the backend tsconfig; include proxies, components, and frontend `.tsx` specifications in the web tsconfig. The root `yarn test` uses two Library Vitest projects: `vite.config.mts` runs backend `for_*/when_*/*.ts` specs in Node with the backend decorator transform, while `vite.frontend.config.mts` runs only frontend `for_*/when_*/*.tsx` specs in jsdom with the same transform. Both use the shared Chai setup; browser specs stub the query hook and command execution and use Testing Library to exercise the co-located `DataPage` and registration dialog without a live kernel. The proxy generator and backend discovery skip `for_*` folders, proxies, and React files, so browser specs never enter the backend build or artifact scan. Let backend lint run on the slice folder. Never hand-edit generated metadata or browser proxies; change the decorated source and regenerate. A bundler reading these slices outside the web project cannot rely on the web tsconfig’s decorator setting: Vite 8 resolves the nearest tsconfig per file. Set `oxc: { decorator: { legacy: true } }` in `Web/vite.config.ts` so co-located proxies compile for browsers in dev and production. Other bundlers must also lower their legacy decorators. A separate generated folder remains an [alternative](/arc/backend/typescript/proxy-generation/getting-started/#separate-output-folders). The Library sample uses `@cratis/components` **inside** the slices: [`AuthorCatalog.tsx`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Listing/AuthorCatalog.tsx) passes `AllAuthors` to `` with a `Column` and an Add `MenuItem`; [`RegisterAuthorForm.tsx`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Registration/RegisterAuthorForm.tsx) uses `CommandDialog` and a bound `InputTextField` with `RegisterAuthor`. `Web/src/App.tsx` wraps the page in `Arc` and `CratisComponentsProvider`; `Web/src/main.tsx` imports Components CSS. The frontend packages imported from `Features/` are declared in `Samples/Library/package.json`; `Web/package.json` declares the app shell’s own dependencies. Both Vite configs deduplicate `react`, `react-dom`, `@cratis/arc`, `@cratis/arc.react`, and `@cratis/components` so co-located hooks, providers, and the app shell share one runtime. Components 4’s built-in renderer needs no PrimeReact dependency. Framework implementations under `Source/` use one type per file instead; this layout is for application code and documentation examples. Working on the samples in this repository has its own scripts, listed in [Change a sample](/arc/backend/typescript/contributing/samples/). # Why Arc for TypeScript > The endpoint plumbing Arc removes from a Node.js backend, who it is built for, what you get in return, and when a different approach fits better. A Node.js backend for a rich frontend writes the same shape for every operation: a route, a body parser, input checks that produce some error format, a call into a service, a status code, a response shape, an authorization check, a tenant lookup, and a typed client in the frontend that someone keeps in step by hand. Add live data and each list also needs its own server-sent events or WebSocket endpoint. None of that is the behavior you set out to write. It is the cost of getting a TypeScript class across HTTP. ## What Arc does instead You put the behavior on the model. A class decorated `@command()` with a `handle()` method is the command, its validation target, and its endpoint. A class decorated `@readModel()` with static `@query()` methods is the read model and the endpoints that serve it. Arc runs every command and query through one pipeline that owns binding, authorization, validation, tenancy, correlation, error redaction, and status codes, and serves the result over the same [HTTP contract](/arc/http-contract/) as Arc on .NET. ``` optional Generated proxy or HTTP client Node host, Express, Fastify, or Hono Arc command or query pipeline @command() or @readModel() class MongoDB, SQL, or Chronicle Decorated TypeScript source arc-proxygenerator ``` `arc-proxygenerator` reads the same decorated source and writes typed proxies for the published `@cratis/arc` client, including React hooks, so the frontend’s types come from the backend’s code. ## What you get | Without Arc | With Arc | | --- | --- | | A route and handler per operation, plus the service call | One decorated class; `handle()` or the static query method is the behavior | | Input checks and error shapes repeated per route | `@field` binding, `CommandValidator` and `ConceptValidator` rules, and one validation result shape with a `/validate` route for every command | | A hand-kept client, or a spec you maintain beside the code | Proxies generated from your TypeScript source, and OpenAPI served from the same metadata | | A custom SSE or WebSocket endpoint per live list | Return an RxJS observable; Arc serves a snapshot, server-sent events, WebSockets, and multiplexed hubs | | Authorization checks sprinkled through handlers | `@authorize`, `@roles`, `@allowAnonymous`, and named policies, evaluated before validation | | Tenant and correlation handling in every handler | One resolved tenant and correlation ID per request, passed to storage integrations | | Hand-written event-log plumbing | Optionally, return Chronicle events from `handle()` and let the integration append them | ## Who it is for * **Teams with an Arc frontend.** If your frontend uses `@cratis/arc` and `@cratis/arc.react`, this server speaks their contract, and the generator writes their proxies. * **Teams running Arc on .NET and Node side by side.** Both implementations follow one HTTP contract, and a paired suite checks the same requests against both; see [How parity is checked](/arc/backend/typescript/reference/capabilities/#how-parity-is-checked). * **CQRS applications, with or without event sourcing.** The core needs no database and no event store. MongoDB, SQL through Drizzle, and Chronicle are separate, optional packages. ## When it is the wrong fit * **You need a published, stable package today.** Nothing is on npm yet, the version is 0.x, and APIs can still change. You build from this repository’s workspace. * **You need everything Arc on .NET does.** Parity is incomplete and tracked area by area. Read the [capability reference](/arc/backend/typescript/reference/capabilities/) before you plan around a feature; the Chronicle integration in particular is experimental. * **You design your HTTP API resource by resource.** Arc’s contract is fixed: commands are POST requests, queries are GET or HTTP `QUERY` requests, and results come in Arc’s envelopes. If clients depend on a hand-shaped REST or custom wire format, Arc’s conventions will fight you. * **You need live queries over SQL.** The Drizzle integration serves snapshots and pages, not change notifications; see [SQL with Drizzle](/arc/backend/typescript/sql/). * **You deploy to a runtime Arc has not been run on.** The Node host and adapters are checked, and the Fetch entry runs in Deno; Bun, Cloudflare Workers, and Next.js deployments have not been exercised. See [Fetch API runtimes](/arc/backend/typescript/hosts/fetch-runtimes/). * **You have a handful of endpoints and no frontend to generate for.** The pipeline and conventions pay off across many operations and a typed client. A single webhook receiver does not need them. ## Where to go next * [Get started](/arc/backend/typescript/getting-started/): run the Tasks sample and call its command and query. * [Hosting overview](/arc/backend/typescript/overview/): choose the standalone Node host or a framework adapter. * [Architecture](/arc/backend/typescript/architecture/): the core, the adapters, and how Arc concepts map to TypeScript. # Capability matrix > Which Arc capabilities exist in each backend implementation - C#, Kotlin, and Java - with the boundary of every partial answer. This matrix covers two backend implementations: [C# on ASP.NET Core](/arc/backend/csharp/), and [Kotlin and Java on Spring Boot](/arc/backend/kotlin/). Use this page to check whether a capability exists in the language you work in before you design around it. Arc for TypeScript [Arc for TypeScript](/arc/backend/typescript/), a third implementation for Node.js, is in source preview and is not in this matrix: a column here needs evidence behind every cell. Its [capability reference](/arc/backend/typescript/reference/capabilities/) gives the status of each capability, the spec or check that proves it, and where it deliberately differs from Arc on .NET. It makes no full-parity claim. Each cell was checked against that implementation’s source, public API, and tests, not against its documentation. A cell says a capability exists. It does not say the two implementations behave identically: where both have a capability but it differs on the wire, [where the implementations differ](/arc/http-contract/#where-the-implementations-differ) describes the difference. The Kotlin and Java evidence behind every row, including which test proves it, is in the [JVM parity reference](/arc/backend/kotlin/reference/parity/). ## Reading the matrix | Status | Meaning | | --- | --- | | Implemented | The capability exists in the current source and public API and is usable from this language. | | Partial | Usable, with the boundary stated in the notes. | | Not planned | Absent from source, and recorded as not planned for that implementation. | | Not applicable | The capability belongs to another platform’s host or toolchain. | A capability one implementation has and the other has neither built nor ruled out is not forced into these states. It is listed under [not yet in every implementation](#not-yet-in-every-implementation). ### What the Java column counts Kotlin and Java share one runtime. The Java column counts capabilities usable through Java-callable APIs; language-specific verification limits are stated separately. Java uses annotations on Java types, `CompletionStage` and `Flow.Publisher` returns, static query methods, and the `Blocking*` and `Async*` contracts rather than implementing suspending methods. Not every Java contract is discovered the same way: * **Collected as Spring beans directly:** `AsyncAuthenticationHandler`, `AsyncIdentityDetailsProvider`, `AsyncUsersProvider`, `AsyncTenantsProvider`, `ConceptValidator`, and `BlockingQueryRendererFor`, `BlockingReadModelInterceptor`, `BlockingObservableQueryEmissionGuard`, and `BlockingReadModelForCommandResolver`, which extend the Kotlin contracts. * **Run only through an adapter bean you declare:** command and query filters, authorization filters and named policies, command and query validators, command execution scopes, and command response value handlers. Wrap the Java implementation in the matching `Blocking*Adapter` or `Async*Adapter` from `io.cratis.arc.java` and return it from a `@Bean` method. A class that implements `BlockingCommandFilter` without that bean is never called. See [pipeline filters](/arc/backend/kotlin/guides/pipeline-filters/). ## Commands | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | Model-bound commands | Implemented | Implemented | Implemented | A command type with a `Handle`/`handle` method. [C#](/arc/backend/csharp/commands/model-bound/) · [Kotlin and Java](/arc/backend/kotlin/guides/commands/) | | Asynchronous handlers | Implemented | Implemented | Implemented | C# `Task`/`ValueTask`, Kotlin `suspend`, Java `CompletionStage`. | | Provided values | Implemented | Implemented | Implemented | `Provide`/`provide` loads data after filters pass and hands it to the handler. [C#](/arc/backend/csharp/commands/model-bound/) · [Kotlin and Java](/arc/backend/kotlin/guides/commands/) | | Command keys | Implemented | Implemented | Implemented | C# `ICanProvideKeyForCommand`; JVM `@CommandKey` and `CommandKeyProvider`. [C#](/arc/backend/csharp/commands/command-context/) · [Kotlin and Java](/arc/backend/kotlin/guides/command-keys/) | | Several return values | Implemented | Implemented | Implemented | C# tuples; JVM `Pair`, `Triple`, `CommandProvidedValues`, and `CommandResponseValues`. Exactly one value may reach the client on the JVM. [C#](/arc/backend/csharp/commands/response-value-handlers/) | | Alternative return values | Implemented | Implemented | Implemented | C# `OneOf` values; JVM `ArcOneOf`. Java can build an `ArcOneOf` through its static `of` and `alternative` factories, but no Java-authored test returns one. Generating named union types (`@GenerateOneOf`) is not planned on the JVM. | | Response value handlers | Implemented | Implemented | Implemented | Java needs an adapter bean. [C#](/arc/backend/csharp/commands/response-value-handlers/) | | Validate without executing | Implemented | Implemented | Implemented | `POST /validate`. [C#](/arc/backend/csharp/commands/validation/) · [Kotlin and Java](/arc/backend/kotlin/guides/validation/) | | Command filters | Implemented | Implemented | Implemented | Java needs an adapter bean. [C#](/arc/backend/csharp/commands/command-filters/) · [Kotlin and Java](/arc/backend/kotlin/guides/pipeline-filters/) | | Command execution scopes | Implemented | Implemented | Implemented | Java needs an adapter bean. [C#](/arc/backend/csharp/commands/command-execution-scopes/) · [Kotlin and Java](/arc/backend/kotlin/guides/execution-scopes/) | | Calling the pipelines from code | Implemented | Implemented | Implemented | Java uses `BlockingCommandPipeline`, `BlockingQueryPipeline`, and `JavaAsyncScope`; blocking calls need explicit per-call or constructor-bound options. [C#](/arc/backend/csharp/commands/command-pipeline/) · [Java](/arc/backend/kotlin/get-started/java/#call-pipelines-from-an-imperative-java-service) | | Controller-based commands and queries | Implemented | Not planned | Not planned | The JVM generates model-bound Spring MVC endpoints only. [C#](/arc/backend/csharp/commands/controller-based/) | ## Queries | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | Model-bound queries | Implemented | Implemented | Implemented | Static methods on a read model; Kotlin uses `@JvmStatic` companion methods. [C#](/arc/backend/csharp/queries/model-bound/) · [Kotlin and Java](/arc/backend/kotlin/guides/queries/) | | HTTP `QUERY` method | Implemented | Implemented | Implemented | [C#](/arc/backend/csharp/queries/using-the-http-query-method/) · [Kotlin and Java](/arc/backend/kotlin/guides/queries/) | | Observable queries | Implemented | Implemented | Implemented | C# `ISubject`/`IObservable`; Kotlin `Flow`/`StateFlow`; Java `Flow.Publisher` and `ObservableState`. [C#](/arc/backend/csharp/queries/model-bound/observable-queries/) · [Kotlin and Java](/arc/backend/kotlin/guides/observable-queries/) | | Observable transports | Implemented | Implemented | Implemented | HTTP snapshot, direct SSE and WebSocket, and the multiplexed hubs. The JVM transport code is shared; its SSE and WebSocket runtime tests run against the Kotlin sample. Snapshot readiness differs; see [the HTTP contract](/arc/http-contract/#observable-http-snapshot-readiness). | | Paging and sorting | Implemented | Implemented | Implemented | The result shapes that get paged differ. C# pages and sorts an `IQueryable` result through [`QueryableQueryRenderer`](https://github.com/Cratis/Arc/blob/main/Source/DotNET/Arc.Core/Queries/QueryableQueryRenderer.cs). The JVM’s default fallback pages and sorts `Iterable` results in memory when no custom renderer matches; custom renderers own their paging and sorting. `QueryPage` and Spring Data `Page` results pass through; a returned array is not sorted or paged, and its paging totals stay at zero. [C#](/arc/backend/csharp/queries/model-bound/paging/) · [Kotlin and Java](/arc/backend/kotlin/guides/queries/) | | Query filters | Implemented | Implemented | Implemented | Java needs an adapter bean. [C#](/arc/backend/csharp/queries/query-pipeline/) · [Kotlin and Java](/arc/backend/kotlin/guides/pipeline-filters/) | | Renderers, read-model interceptors, and emission guards | Implemented | Implemented | Implemented | [C#](/arc/backend/csharp/queries/read-model-interception/) · [Kotlin and Java](/arc/backend/kotlin/guides/queries/) | | Services in query methods | Implemented | Implemented | Implemented | Service parameters are injected and never become client arguments. [C#](/arc/backend/csharp/queries/model-bound/dependency-injection/) · [Kotlin and Java](/arc/backend/kotlin/guides/queries/) | | Spring Data `Pageable`, `Sort`, and `Page` | Not applicable | Implemented | Implemented | [Kotlin and Java](/arc/backend/kotlin/guides/spring-data/repositories/) | | Query health endpoint | Implemented | Implemented | Implemented | `/.cratis/queries/health`. Its authentication differs; see [the HTTP contract](/arc/http-contract/#query-health-is-anonymous-on-net). [C#](/arc/backend/csharp/queries/query-health/) | ## Validation | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | Command and query validators | Implemented | Implemented | Implemented | Java needs an adapter bean. [C#](/arc/backend/csharp/commands/validation/) · [Kotlin and Java](/arc/backend/kotlin/guides/validation/) | | Validation severity | Implemented | Implemented | Implemented | Warnings and information alongside errors, and treating warnings as errors. [C#](/arc/backend/csharp/commands/validation-severity-filtering/) · [Kotlin and Java](/arc/backend/kotlin/guides/validation/) | | Concept validators | Implemented | Implemented | Implemented | One rule for a concept, applied wherever the concept appears. [C#](/arc/backend/csharp/queries/validation/) · [Kotlin and Java](/arc/backend/kotlin/guides/validation/) | | Annotation rules in generated proxies | Implemented | Implemented | Implemented | C# DataAnnotations; JVM Jakarta Bean Validation, which covers more annotations. [C#](/arc/backend/csharp/proxy-generation/validation/) · [Kotlin and Java](/arc/backend/kotlin/reference/validation/) | | Validator rules shared with the client | Implemented | Implemented | Implemented | Each side sends only the rules it recognizes. C# extracts the FluentValidation rules its proxy generator knows; the JVM `FluentModelValidator` supports thirteen literal rules and no warning severity. [C#](/arc/backend/csharp/proxy-generation/validation/) · [Kotlin and Java](/arc/backend/kotlin/guides/validation/) | | Opting one member out of validation | Partial | Implemented | Implemented | C# [`[IgnoreValidation]`](https://github.com/Cratis/Arc/blob/main/Source/DotNET/Arc/IgnoreValidationAttribute.cs) applies to a controller class or action, not to a model-bound artifact or one member; one member can skip concept validators with [`IgnoreConceptRules()`](https://github.com/Cratis/Arc/blob/main/Source/DotNET/Arc.Core/Validation/IConceptRuleBuilder.cs). JVM `@IgnoreValidation` skips one member and everything below it. [Kotlin and Java](/arc/backend/kotlin/reference/validation/) | ## Security and identity | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | Anonymous, authenticated, and role authorization | Implemented | Implemented | Implemented | An operation’s own declaration replaces its type’s. [C#](/arc/backend/csharp/authorizing-commands-and-queries/) · [Kotlin and Java](/arc/backend/kotlin/guides/security/) | | Named policies and authentication schemes | Implemented | Implemented | Implemented | C# runs scoped asynchronous native policies on either host and ASP.NET Core policies on that host. Actual scheme authentication requires ASP.NET Core and an Arc-owned execution scope; standalone Core rejects scheme requirements. The JVM checks the caller’s already-captured scheme rather than authenticating another scheme. Java policies need an adapter bean. [C# policies](/arc/backend/csharp/core/authorization/#register-a-named-policy) · [C# scheme boundaries](/arc/backend/csharp/asp-net-core/authorization/#policy-based-authorization) · [Kotlin and Java](/arc/backend/kotlin/guides/security/) | | Authentication handlers | Implemented | Implemented | Implemented | Java implements `AsyncAuthenticationHandler`. [C#](/arc/backend/csharp/core/authentication/) · [Kotlin and Java](/arc/backend/kotlin/guides/security/) | | Identity details and `/.cratis/me` | Implemented | Implemented | Implemented | [C#](/arc/backend/csharp/identity/) · [Kotlin and Java](/arc/backend/kotlin/guides/security/) | | Microsoft identity platform headers | Implemented | Implemented | Implemented | `x-ms-client-principal`. Ignored on C# until the host sets `TrustForwardedIdentityHeaders`. Off by default on the JVM; enabling it also requires an application `ArcPlatformIdentityTrust` bean because the default trusts no request. [C#](/arc/backend/csharp/asp-net-core/microsoft-identity/) · [Kotlin and Java](/arc/backend/kotlin/guides/security/) | | Development users and tenants | Implemented | Implemented | Implemented | `/.cratis/users` and `/.cratis/tenants`. [C#](/arc/backend/csharp/identity/development-and-topologies/) · [Kotlin and Java](/arc/backend/kotlin/guides/development-users-and-tenants/) | | Tenant resolution | Implemented | Implemented | Implemented | Fixed, header, query string, claim, subdomain, and development resolvers. Enforcement differs; see [the HTTP contract](/arc/http-contract/#tenant-resolution-strategy-and-enforcement). [C#](/arc/backend/csharp/tenancy/resolvers/) · [Kotlin and Java](/arc/backend/kotlin/guides/ambient-tenancy/) | ## Persistence | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | MongoDB | Implemented | Implemented | Implemented | C# `Cratis.Arc.MongoDB`; JVM Spring Data MongoDB. [C#](/arc/backend/csharp/mongodb/) · [Kotlin and Java](/arc/backend/kotlin/guides/spring-data/) | | Relational databases | Implemented | Implemented | Implemented | C# Entity Framework Core; JVM Spring Data JPA. [C#](/arc/backend/csharp/entity-framework/) · [Kotlin and Java](/arc/backend/kotlin/guides/spring-data/) | | Observable queries from database changes | Implemented | Implemented | Implemented | C# observes MongoDB collections and EF Core sets. The JVM uses MongoDB change streams, which need a replica set or sharded cluster, and explicit in-process JPA notifications. [C#](/arc/backend/csharp/mongodb/observing-collections/) · [Kotlin and Java](/arc/backend/kotlin/guides/spring-data/observable-snapshots/) | | Read models in command handlers | Implemented | Implemented | Implemented | Arc resolves a read model by command key and hands it to the handler. [C#](/arc/backend/csharp/chronicle/read-models/other-providers/) · [Kotlin and Java](/arc/backend/kotlin/guides/spring-data/repositories/) | ## Chronicle integration | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | Events returned from commands | Implemented | Implemented | Implemented | Appended only when the command succeeds. [C#](/arc/backend/csharp/chronicle/commands/events/) · [Kotlin and Java](/arc/backend/kotlin/guides/chronicle/) | | Concurrency scopes | Implemented | Implemented | Implemented | [C#](/arc/backend/csharp/chronicle/commands/concurrency/) · [Kotlin and Java](/arc/backend/kotlin/guides/chronicle/) | | Chronicle read models in commands and queries | Implemented | Implemented | Implemented | [C#](/arc/backend/csharp/chronicle/read-models/injecting-into-commands/) · [Kotlin and Java](/arc/backend/kotlin/guides/chronicle/) | | Commands from reactors | Implemented | Implemented | Implemented | Both let a reactor declare the system roles its commands run with through `ExecuteCommandsAsSystem`. A JVM reactor hands command values to `ChronicleCommandSideEffectHandler`. [C#](/arc/backend/csharp/chronicle/reactors/command-side-effects/) · [Kotlin and Java](/arc/backend/kotlin/guides/chronicle/) | | Chronicle command scenarios | Implemented | Implemented | Implemented | An in-memory event log; no Chronicle kernel runs. [C#](/arc/backend/csharp/testing/chronicle/) · [Kotlin and Java](/arc/backend/kotlin/guides/testing/) | ## Testing | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | In-process scenarios | Partial | Implemented | Implemented | C# has command and snapshot query scenarios, but no observable-query scenario. The JVM has command, query, and observable query scenarios; Java uses the `Blocking*` and `Async*` scenario classes. [C# commands](/arc/backend/csharp/testing/command-scenario/) · [C# queries](/arc/backend/csharp/testing/query-scenario/) · [Kotlin and Java](/arc/backend/kotlin/guides/testing/) | ## Tooling and clients | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | TypeScript proxies for commands and queries | Implemented | Implemented | Implemented | [C#](/arc/backend/csharp/proxy-generation/) · [Kotlin and Java](/arc/backend/kotlin/guides/typescript-proxies/) | | TypeScript proxies for observable queries | Implemented | Implemented | Implemented | [C#](/arc/backend/csharp/proxy-generation/) · [Kotlin and Java](/arc/backend/kotlin/guides/typescript-proxies/) | | Concepts | Implemented | Implemented | Implemented | `ConceptAs` crosses the wire as its underlying value. [C#](/arc/backend/csharp/proxy-generation/type-mapping/) · [Kotlin and Java](/arc/backend/kotlin/reference/annotations/) | | Polymorphic derived types | Implemented | Implemented | Implemented | A `_derivedTypeId` discriminator. A JVM hierarchy that arrives only as a dependency binary needs a `DerivedTypeRegistrar`. [Kotlin and Java](/arc/backend/kotlin/reference/annotations/) | | `DateOnly`, `TimeOnly`, and `Guid` in proxies | Implemented | Implemented | Implemented | JVM `Duration` is ISO-8601 text, not `TimeSpan`. [C#](/arc/backend/csharp/proxy-generation/type-mapping/) · [Kotlin and Java](/arc/backend/kotlin/guides/typescript-proxies/) | | Documentation comments in proxies | Implemented | Implemented | Implemented | C# XML documentation; Kotlin KDoc; Java Javadoc. Which declarations each side documents has not been compared. | | Mapping types to TypeScript packages | Implemented | Implemented | Implemented | C# maps by assembly and by type; the JVM maps by package and by type. [C#](/arc/backend/csharp/proxy-generation/configuration/assembly-package-mappings/) · [Kotlin and Java](/arc/backend/kotlin/guides/typescript-proxies/) | | Types outside commands and queries | Implemented | Implemented | Implemented | C# library mode; JVM `@ExportedType` and identity details roots. [C#](/arc/backend/csharp/proxy-generation/configuration/library-mode/) · [Kotlin and Java](/arc/backend/kotlin/reference/annotations/) | | Build-time diagnostics | Implemented | Implemented | Implemented | C# Roslyn analyzers; JVM KSP diagnostics, which also cover Java sources. [C#](/arc/backend/csharp/code-analysis/) · [Kotlin and Java](/arc/backend/kotlin/reference/diagnostics/) | | OpenAPI | Implemented | Implemented | Implemented | [C#](/arc/backend/csharp/open-api/) · [Kotlin and Java](/arc/backend/kotlin/guides/openapi/) | | Introspection endpoints | Implemented | Implemented | Implemented | `/.cratis/commands` and `/.cratis/queries`. Metadata depth differs; see [the HTTP contract](/arc/http-contract/#introspection-metadata-depth). [C#](/arc/backend/csharp/introspection/) | | Screenplay generation | Implemented | Not planned | Not planned | [C#](/arc/backend/csharp/generating-a-screenplay/) | ## Hosting | Capability | C# | Kotlin | Java | Notes | | --- | --- | --- | --- | --- | | ASP.NET Core host | Implemented | Not applicable | Not applicable | [C#](/arc/backend/csharp/asp-net-core/) | | Spring Boot host | Not applicable | Implemented | Implemented | [Kotlin and Java](/arc/backend/kotlin/get-started/) | | Standalone HTTP host outside ASP.NET Core or Spring Boot | Implemented | Not planned | Not planned | C# `ArcApplication`. [C#](/arc/backend/csharp/core/) | | Static files and SPA fallback | Implemented | Not planned | Not planned | Spring Boot serves static resources itself. [C#](/arc/backend/csharp/core/static-files/) | | `[FromRequest]` binding | Implemented | Not planned | Not planned | [C#](/arc/backend/csharp/asp-net-core/from-request/) | | Opting a type out of auto-registration | Implemented | Not applicable | Not applicable | C# `[IgnoreAutoRegistration]` excludes a `DbContext` from Entity Framework Core hookup. JVM entry points are opt-in annotations. [C#](/arc/backend/csharp/entity-framework/automatic-database-hookup/) | | Correlation IDs | Implemented | Implemented | Implemented | `X-Correlation-ID` on every request. Edge cases differ; see [the HTTP contract](/arc/http-contract/#correlation-identifier-edge-cases). | | Observability | Implemented | Implemented | Implemented | C# `ActivitySource` tracing; JVM Micrometer observations. [C#](/arc/backend/csharp/observability/) · [Kotlin and Java](/arc/backend/kotlin/guides/observability/) | ## Not yet in every implementation These C# capabilities have no Kotlin or Java counterpart in source, and no decision records them as not planned for the JVM. They are listed here rather than marked not planned. | Capability | Available in | Kotlin and Java | | --- | --- | --- | | [Command operations](/arc/backend/csharp/commands/operations/) | C# | Not in source; no decision recorded. | | [Aggregate roots](/arc/backend/csharp/chronicle/aggregates/) | C# | Not in source; no decision recorded. | ## What this page leaves out The JVM parity reference also tracks rows that describe how the JVM is verified rather than a capability you adopt: the proxy differential gates, the runtime and HTTP conformance gates, binary compatibility, runtime hardening, Kotlin ergonomics, and the Java adapters themselves. It records cross-store transactions as not planned; neither implementation provides one. Admission control and request limits are compared in [the HTTP contract](/arc/http-contract/#admission-control-and-request-limits). ## Related * [HTTP contract](/arc/http-contract/) - the wire protocol both implementations speak * [JVM parity reference](/arc/backend/kotlin/reference/parity/) - the evidence behind the Kotlin and Java columns * [TypeScript capability reference](/arc/backend/typescript/reference/capabilities/) - status and evidence for Arc for TypeScript * [C# backend](/arc/backend/csharp/) and [Kotlin and Java backend](/arc/backend/kotlin/) # Connection resolution contract > The language-neutral REST contract an endpoint implements to tell Arc and Chronicle where to connect and which identity to use, for MongoDB and SQL storage. This page specifies the endpoint behind the `Http` connection resolver. When Arc or Chronicle needs a storage connection, it calls your endpoint and asks where to connect and who to connect as. The contract is the same for every storage, MongoDB as well as SQL Server, PostgreSQL and SQLite, and it is plain JSON over HTTP, so you can implement the endpoint in any language or platform. For how the resolver is configured on the caller’s side, see [connection resolution](/arc/backend/csharp/tenancy/connection-resolution/). The calling host decides which values it sends in the request `scope`. Arc’s are listed under [Scope values](#scope-values); Chronicle documents its own in [storage connection resolution](/chronicle/hosting/configuration/storage-connection-resolution/). ## Operation | | | | --- | --- | | Method | `POST` | | URL | The configured endpoint, used exactly as configured. The caller adds no path or query string. | | Request body | `application/json`, UTF-8. See [Request](#request). | | Request headers | `Content-Type: application/json; charset=utf-8`, plus every header configured for the resolver, such as an API key. With authentication configured, also `Authorization: Bearer `. | | Response body | `application/json` on `200 OK`. See [Response](#response). | | Timeout | 10 seconds unless configured otherwise. A timeout is a failure. | The caller sends one request per storage, purpose and scope, and caches the answer (see [Caching](#caching)). Calls happen when a connection is about to be used, so your endpoint sits on the path of the first data access after a cache entry expires. Keep it fast. ## Request ```json { "storage": "SqlServer", "purpose": "Runtime", "scope": { "tenant": "acme" } } ``` | Property | Type | Required | Description | | --- | --- | --- | --- | | `storage` | string | Yes | The kind of storage being connected to: `MongoDB`, `SqlServer`, `PostgreSql` or `Sqlite`. Treat it as an open set and answer an unknown value with `204` or an error. | | `purpose` | string | Yes | `Runtime` for reading and writing data, `Schema` for schema changes such as migrations or creating tables. Use it to hand out a more privileged identity for `Schema` only. | | `scope` | object | Yes | String keys to string values identifying what is being connected, such as a tenant. Never `null`, possibly empty. The keys depend on the calling host. | Ignore properties you do not recognize. New ones may be added without notice. ### Scope values When Arc calls the endpoint, the scope holds one key: | Key | Value | | --- | --- | | `tenant` | The tenant of the current request. It is `Default` when no tenant is set. | Chronicle sends a different set, describing the cluster, event store or namespace it connects for. See [storage connection resolution](/chronicle/hosting/configuration/storage-connection-resolution/). ## Response ### Status codes | Status | Meaning | | --- | --- | | `200 OK` | The body is a resolution, described below. Parts present in it are applied; parts left out keep their configured value. `{}` changes nothing. | | `204 No Content` | Keep the configured connection unchanged. Use it when you have nothing to say about this request. | | Any other status | Resolution fails, and so does the connection that needed it. The caller does not read the body and does not retry immediately; the next connection attempt calls you again. | Redirects are followed. A `200 OK` whose body is not a JSON object is a failure. ### Body Every property is optional and may be left out or set to `null`. ```json { "connectionString": "Server=sql-eu-2.example;Encrypt=true", "database": "tenant-acme", "schema": "acme", "identity": { "accessToken": "eyJ0eXAiOiJKV1Qi..." }, "expiresAt": "2026-10-16T12:00:00+00:00" } ``` | Property | Type | Description | | --- | --- | --- | | `connectionString` | string | Replaces the configured connection string, in the native format of the storage: a `mongodb://` or `mongodb+srv://` URI for MongoDB, an ADO.NET style string for SQL Server, PostgreSQL and SQLite. | | `database` | string | The database to use. Replaces the database in the connection string. For SQLite this is the database file. | | `schema` | string | The schema to use, for storage that has schemas. Ignored where the calling host does not support placing data in a schema. | | `identity` | object | Who to connect as. Replaces any identity resolved before it as a whole. See [Identity](#identity). | | `expiresAt` | string | When this answer stops being valid, as an ISO 8601 date and time with an offset. Set it whenever you return a short-lived credential. | Property names are matched without regard to case, and unknown properties are ignored. ### Identity | Property | Type | Description | | --- | --- | --- | | `userName` | string | User name for password authentication. | | `password` | string | Password for password authentication. | | `accessToken` | string | A bearer access token, such as one issued by Microsoft Entra ID or another OpenID Connect provider. | Return either a user name and password, or an access token. How each storage uses them: | Storage | `userName` + `password` | `accessToken` | | --- | --- | --- | | MongoDB | SCRAM authentication against the connection string’s `authSource`, else its database, else `admin` | `MONGODB-OIDC` authentication. The caller asks you again when the driver needs a fresh token. | | SQL Server | SQL authentication | Entra ID token authentication. The token must be issued for `https://database.windows.net/`. | | PostgreSQL | Password authentication | Sent as the password, as Azure Database for PostgreSQL and Amazon RDS IAM authentication expect. | | SQLite | Ignored | Ignored | ## Two slots, one endpoint or two The caller has two slots, `Connection` and `Identity`, and each can point to its own endpoint. Both endpoints receive the same request and implement this same contract. The caller applies the answer from the `Connection` slot first and the answer from the `Identity` slot on top of it, so an answer only needs to contain the parts its endpoint is responsible for. An identity-only endpoint returns only `identity` and `expiresAt`. ## Caching The caller keeps each answer **in memory**, keyed by `storage`, `purpose` and `scope`, never by credential: * With `expiresAt`, the answer is used until one minute before that time, and then resolved again. Return a later `expiresAt` with every fresh credential. * Without `expiresAt`, the answer is used for the configured cache duration, five minutes by default. * When a connection fails to authenticate, the caller drops the cached answer, and the next connection asks you again. Rotating a credential therefore takes effect at the latest on the next failed sign-in. A `204` is cached like an answer that changes nothing. ## Security * Serve the endpoint over HTTPS only. Answers contain credentials. * Authenticate the caller. Prefer a short-lived bearer token over a static API key: on Azure, have the caller sign in with a managed identity or workload identity and send a Microsoft Entra ID token in `Authorization: Bearer`. * Validate every token: its signature, its issuer (your tenant), its audience (your endpoint’s application ID URI) and its expiry. Require an app role, and register the endpoint as an enterprise application with assignment required, so only the identities you assigned get a token at all. * Answer `401` when the token is missing, expired or invalid, and `403` when it is valid but lacks the role. The caller retries a `401` once with a fresh token and does not retry a `403`; either way it fails instead of connecting. * You do not have to write the validation yourself: App Service authentication or the API Management `validate-jwt` policy can do it in front of the endpoint. * Network isolation, such as a private endpoint or access restrictions, complements token validation; it does not replace it. * Authorize the scope. Only return a connection that the caller may use for the tenant or namespace it asked about. Resolution chooses a destination; it is not an authorization check on the end user. * Prefer short-lived access tokens with an `expiresAt` over long-lived passwords in your answers. * Do not log response bodies or tokens. The caller never logs the credentials it receives or the tokens it sends. ## OpenAPI description ```yaml openapi: 3.1.0 info: title: Connection resolution version: "1.0" paths: /: post: summary: Resolve where and as whom to connect to a storage. description: The path is whatever the resolver is configured with. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ConnectionRequest" responses: "200": description: Apply the parts that are present. content: application/json: schema: $ref: "#/components/schemas/ResolvedConnection" "204": description: Keep the configured connection unchanged. default: description: Resolution failed. The connection is not opened. components: schemas: ConnectionRequest: type: object required: [storage, purpose, scope] properties: storage: type: string description: The kind of storage. An open set. examples: [MongoDB, SqlServer, PostgreSql, Sqlite] purpose: type: string enum: [Runtime, Schema] scope: type: object additionalProperties: type: string ResolvedConnection: type: object properties: connectionString: type: [string, "null"] database: type: [string, "null"] schema: type: [string, "null"] identity: oneOf: - $ref: "#/components/schemas/ConnectionIdentity" - type: "null" expiresAt: type: [string, "null"] format: date-time ConnectionIdentity: type: object properties: userName: type: [string, "null"] password: type: [string, "null"] accessToken: type: [string, "null"] ``` # Frontend > How Arc turns your backend commands and queries into typed React hooks — call your backend like a local function, with validation and live updates included. Building a React frontend against a backend usually means writing the same plumbing over and over: a `fetch` call here, a hand-written request type there, validation rules copied from the server, and a pile of state to track whether the request is in flight, succeeded, or failed. Every time the backend changes, you go hunting for the frontend code that drifted out of sync. Arc removes that whole layer. C#, JVM, and the [TypeScript source preview](/arc/backend/typescript/) can generate typed TypeScript proxies from backend commands and queries. On the frontend you import a proxy and call its `.use()` hook — request and response types and supported client-safe validation rules come along. Regenerate after a backend change and type-check the frontend to catch structural drift. The frontend uses the same `@cratis/arc` runtime, but backend capabilities and generator coverage differ. Check the [TypeScript capability reference](/arc/backend/typescript/reference/capabilities/) before applying a C# or JVM example to the preview. ## How the pieces fit ``` proxy generator .use() hook executes / observes Backend command / query Generated TS proxy React component Arc endpoint ``` You write the backend once. Configure and run that backend’s proxy generator to emit a typed client; TypeScript generation is an explicit source-based step, not implicit in every backend build. Your React code consumes it through each proxy’s static `.use()` hook — and because the types flow across the boundary, there is no DTO to keep in sync and no untyped JSON to second-guess. ## Execute a command from React A command is an intent to change something — *open an account*, *check out a book*. You define it on the backend; Arc generates a proxy whose static `.use()` hook gives you an instance, explicit React value updates, change tracking, and execution. The following backend fragment assumes the application’s `AccountId`, `AccountHolder`, `Account`, and its persistence registration; it is not a complete backend bootstrap. * C# ```csharp [Command] public record OpenAccount(AccountId Id, AccountHolder Owner) { public Task Handle(IMongoCollection accounts) => accounts.InsertOneAsync(new Account(Id, Owner)); } ``` * Java ```java @Command public record OpenAccount(AccountId id, AccountHolder owner) { public void handle(@FromServices AccountRepository accounts) { accounts.save(new Account(id, owner)); } } ``` * Kotlin ```kotlin @Command data class OpenAccount(val id: AccountId, val owner: AccountHolder) { suspend fun handle(@FromServices accounts: AccountRepository) { accounts.save(Account(id, owner)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; @command() export class OpenAccount { @field(AccountId) id!: AccountId; @field(AccountHolder) owner!: AccountHolder; @inject(AccountRepository) handle(accounts: AccountRepository): Promise { return accounts.save({ id: this.id, owner: this.owner }); } } ``` The generated clients use the same frontend runtime, so the React side below is written once: ```tsx // Frontend — the generated proxy, driven by its static .use() hook import { useState } from 'react'; import { Guid } from '@cratis/fundamentals'; import { OpenAccount } from './api/accounts/OpenAccount'; export const OpenAccountForm = () => { const [id] = useState(() => Guid.create()); const [command, setValues] = OpenAccount.use({ id, owner: '' }); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [message, setMessage] = useState(''); const submit = async () => { setSaving(true); try { const result = await command.execute(); setSaved(result.isSuccess); setMessage(result.isSuccess ? 'Account opened.' : 'Could not open account. You can retry.'); } finally { setSaving(false); } }; return ( <> setValues({ owner: e.target.value })} />

{message}

); }; ``` The `.use()` hook returns `[command, setValues, clearValues]`. Use the setter for reliable per-edit React rendering; raw assignment is change tracking, not a guarantee of every render or automatic validation. Set required properties explicitly: here a Guid-based `AccountId` needs a Fundamentals `Guid`, created once for this operation. A reusable create form must start a fresh operation after success, as the [getting-started checkpoint](/arc/frontend/react/getting-started/) shows. Call `command.execute()` and check `result.isSuccess`. The button tracks in-flight and successful completion explicitly, not `hasChanges`: a rejected request can update the change-tracking baseline, so that flag alone can disable a needed retry. See [execution baseline limitations](/arc/frontend/react/commands/data-binding/#execution-baseline-limitations). Supported projected validation rules run on the client; server validation remains authoritative. Not every backend rule can be projected. Don't hand-roll forms For real forms, reach for the [Command form](/arc/frontend/react/command-form/) fields and the [Components](/components/) library’s `CommandDialog` instead of wiring inputs by hand — they bind to the command, surface validation, and handle the in-flight state for you. ## Show live data with an observable query A query reads data. An *observable* query keeps reading — its `.use()` hook holds a live connection and re-renders your component whenever the underlying read model changes, so your UI stays current without polling. ```tsx import { AllAccounts } from './api/accounts/AllAccounts'; export const AccountsList = () => { const [accounts] = AllAccounts.use(); // observable: re-renders as the read model changes return (
    {accounts.data.map((account) => (
  • {account.owner}
  • ))}
); }; ``` The result is a `QueryResultWithState` — `accounts.data` holds the rows, with `isPerforming`, `hasData`, and validation state alongside. The backend must supply an emitting observable source, such as MongoDB `Observe()` on C# or an `ObservableSource` on TypeScript. When that source emits changed data, subscribed consumers receive the new list without polling. A command write alone does not establish this live connection; see [backend observable queries](/arc/backend/csharp/queries/model-bound/observable-queries/). ## Two ways to build: hooks or MVVM Most screens are simplest with the **hooks** shown above — they keep state in the component and read naturally. For complex, stateful screens you can opt into a **Model-View-ViewModel** structure that moves logic into a view model class. | Reach for… | When | | --- | --- | | [Hooks](/arc/frontend/react/) | The default. Forms, lists, and most screens — less boilerplate, state stays in the component. | | [MVVM for React](/arc/frontend/react/mvvm/) | Complex screens with substantial view logic you want to test and reuse apart from the markup. | Start with hooks; move a screen to MVVM only when its logic outgrows the component. ## Where to go next [Getting started (React) ](/arc/frontend/react/getting-started/)Wire Arc into a React app and call your first command and query. [Commands in React ](/arc/frontend/react/commands/)The .use() hook, validation, and command scopes. [Queries in React ](/arc/frontend/react/queries/)Observable, paged, and conditional queries. [Command forms ](/arc/frontend/react/command-form/)Typed form fields that bind straight to a command. [Identity in React ](/arc/frontend/react/identity/)Who the user is and what they're allowed to do. [Components ](/components/)Prebuilt dialogs, forms, and tables that consume these proxies. # Core > The framework-agnostic TypeScript layer beneath Arc's React hooks, and when to use it directly outside React. The core is Arc’s frontend layer **beneath React** — the framework-agnostic TypeScript that the React hooks build on. Most apps consume it indirectly through [React](/arc/frontend/react/); reach for it directly when you’re working outside React (a service layer, a worker, a vanilla-TS integration) or you need lower-level control over how commands and queries execute. | Topic | What it covers | | --- | --- | | [Commands](/arc/frontend/core/commands/) | Instantiating and executing commands without React — the runtime behind the hooks. | | [Queries](/arc/frontend/core/queries/) | Retrieving data and subscribing to observable queries at the core level. | | [Validation](/arc/frontend/core/validation/) | The validation rules and results that flow through commands and queries. | | [Identity](/arc/frontend/core/identity/) | The user-context primitives the higher layers expose. | | [Messaging](/arc/frontend/core/messaging/) | The messaging and communication patterns Arc uses under the hood. | Building UI? You almost certainly want [React](/arc/frontend/react/) instead — it wraps everything here in hooks. # Commands > Overview of Arc's low-level TypeScript command primitives in @cratis/arc and where each capability is documented. Core commands in Arc are the low-level TypeScript/JavaScript primitives used to execute state-changing operations. This section focuses on command contracts and runtime behavior in `@cratis/arc`. For React-specific ergonomics, see [React Commands](/arc/frontend/react/commands/). ## Capabilities | Capability | What It Covers | Learn More | | --- | --- | --- | | Command contracts | `ICommand`, route metadata, execution shape, and command state model | [Command Contracts](/arc/frontend/core/commands/contracts/) | | Runtime configuration | Microservice routing, API base path, and global Arc configuration | [Configuration](/arc/frontend/core/commands/configuration/) | | Validation and results | Client-side validation, `CommandResult`, and error categories | [Validation And Results](/arc/frontend/core/commands/validation-and-results/) | | Backend integration | Controller-based/model-bound backend mapping and proxy generation | [Backend Integration](/arc/frontend/core/commands/integration/) | ## Related Documentation * [Command Result](/arc/frontend/core/commands/command-result/) * [Validation](/arc/frontend/core/commands/validation/) * [Validation](/arc/frontend/core/validation/) * [Backend Commands](/arc/backend/csharp/commands/) # CommandResult > Read a CommandResult: success, authorization, validation, exception details, and response values, and chain handlers per outcome. When a command is executed, it returns a `CommandResult` that provides comprehensive information about the execution outcome. The result includes success/failure status, validation errors, authorization status, exceptions, and optional response data. ## Structure The `CommandResult` interface provides the following properties: ```typescript interface ICommandResult { readonly correlationId: Guid; readonly isSuccess: boolean; readonly isAuthorized: boolean; readonly isValid: boolean; readonly hasExceptions: boolean; readonly validationResults: ValidationResult[]; readonly exceptionMessages: string[]; readonly authorizationFailureReason: string; readonly exceptionStackTrace: string; readonly response?: TResponse; } ``` `authorizationFailureReason` carries the reason a command was not authorized when the server supplies one; it is empty otherwise. ## Status Properties Use the status properties to distinguish these outcomes: ### isSuccess Indicates whether the command executed successfully **overall**. This is the primary indicator of whether the operation completed as intended. * `true`: The command executed without any issues (authorized, valid, and no exceptions) * `false`: The command failed for one or more reasons (could be authorization, validation, or exceptions) **Use this when:** * You want to know if the overall operation succeeded * You need a single boolean check for success/failure * You’re implementing simple success/failure UI feedback ```typescript const result = await command.execute(); if (result.isSuccess) { // Show success message } else { // Handle failure (check other properties for details) } ``` ### isAuthorized Indicates whether Arc’s command pipeline authorized this command. * `true`: The result reports no authorization failure. A client-validation short circuit can produce this without running server authorization; it is not an access grant. * `false`: The generated command endpoint returned an authorization failure, typically HTTP 403. An upstream authentication challenge can return HTTP 401 before a `CommandResult` payload is produced. **Use this when:** * You need to distinguish authorization failures from other errors * You want to show specific “access denied” messages * You need to redirect users to login or show permission requests ```typescript const result = await command.execute(); if (!result.isAuthorized) { // Show "Access Denied" message // Redirect to login or request permissions } ``` ### isValid Indicates whether the command payload passed validation rules. * `true`: All validation rules passed * `false`: One or more validation rules failed (check `validationResults` for details) **Use this when:** * You need to display validation errors to users * You want to highlight invalid form fields * You’re implementing client-side validation feedback ```typescript const result = await command.execute(); if (!result.isValid) { // Display validation errors from result.validationResults result.validationResults.forEach((error) => { console.log(`${error.members.join('.')}: ${error.message}`); }); } ``` ### hasExceptions Indicates whether any exceptions occurred during command execution. * `true`: One or more exceptions were thrown * `false`: No exceptions occurred **Use this when:** * You need to catch unexpected server errors * You want to log errors for debugging * You need to show generic error messages for system failures ```typescript const result = await command.execute(); if (result.hasExceptions) { // Log exception details console.error('Exceptions:', result.exceptionMessages); console.error('Stack trace:', result.exceptionStackTrace); // Show user-friendly error message showErrorMessage('An unexpected error occurred. Please try again.'); } ``` ## Understanding the Relationship The status properties work together to provide a complete picture: ```typescript // Scenario 1: Complete success // isSuccess = true, isAuthorized = true, isValid = true, hasExceptions = false // Scenario 2: Authorization failure // isSuccess = false, isAuthorized = false, isValid = true, hasExceptions = false // Scenario 3: Validation failure // isSuccess = false, isAuthorized = true, isValid = false, hasExceptions = false // Scenario 4: Exception occurred // isSuccess = false, isAuthorized = true, isValid = true, hasExceptions = true // Scenario 5: Multiple failures (validation + exception) // isSuccess = false, isAuthorized = true, isValid = false, hasExceptions = true ``` **Key principle:** If `isSuccess` is `false`, check the other properties to determine **why** it failed. ## Accessing Data ### Response Data When the command returns data, access it through the `response` property: ```typescript interface CreateAccountResponse { accountId: string; accountNumber: string; } const result = await createAccountCommand.execute(); if (result.isSuccess && result.response) { const { accountId, accountNumber } = result.response; console.log(`Account created: ${accountNumber}`); } ``` The response is typed according to the command’s generic parameter, providing full type safety. Generated proxies preserve collection responses. A C# command returning `IEnumerable` generates `Command` and uses `ProjectSummary[]` as the response type in its React `useCommand` hook. Calling `execute()` returns `CommandResult`, so you can iterate or map `result.response` after checking that it is present, without casting it. An `IEnumerable` response becomes `Record[]`; a scalar `ProjectSummary` response remains `ProjectSummary`. Commands without response data omit the response generic and retain the default `object` type. ### Validation Results Access detailed validation errors through the `validationResults` array: ```typescript const result = await command.execute(); if (!result.isValid) { result.validationResults.forEach((error) => { console.log(`Severity: ${error.severity}`); console.log(`Message: ${error.message}`); console.log(`Members: ${error.members.join('.')}`); console.log(`State: ${JSON.stringify(error.state)}`); console.log(`Reason: ${error.reason}`); }); } ``` Each `ValidationResult` contains: * `severity`: The severity level of the validation error * `message`: A developer-facing message. Only meant to be shown to a user when `reason` is `rule` * `members`: Array of property names that failed validation * `state`: Additional context, set by whoever authored the rule (FluentValidation’s `WithState`) * `reason`: What composed the result — see below * `reasonDetail`: Which specific thing within `reason` produced the result — the violated constraint for `constraintViolation`, or `ReadChanged` / `ReadIncomplete` for read-protection `concurrencyViolation` results. `undefined` when the reason carries no finer identity ### Telling one kind of rejection from another A rejection your own rules produced and one the framework composed on your behalf arrive in the same array, in the same shape. `reason` is what separates them, so you never have to match the message text: | `reason` | What happened | What to do | | --- | --- | --- | | `rule` | A rule you authored rejected the input. The default. | Show `message` — it is your copy | | `concurrencyViolation` | Optional Chronicle integration: the event source moved on | Re-read and offer a deliberate retry | | `constraintViolation` | Optional Chronicle integration: an event-store constraint rejected the append | Map the constraint name to your own copy | | `validatorFailed` | A validator threw; nothing the author wrote survives | Show your own generic copy, and check the server log | | `dependencyUnavailable` | A dependency needed to evaluate the command was unavailable | Do not imply the input passed or failed its rules | | `malformedRequest` | The request body or a bound value could not be read; C# supplies a normalized member when available | Correct the request; rules were not reached. Ordinary parser details stay private; explicit-offset refusals have a showable message. | | `frameworkFailure` | The framework could not fulfill a command contract or complete its effects | Use your own copy; this framework-composed message is not meant for end users | ### Optional Chronicle rejection handling The next two fragments apply only when using the [Chronicle command integration](/arc/backend/csharp/chronicle/commands/). Standalone Arc has no event-store dependency. Commands can call ordinary services and return DTOs or `Guid` responses without any event-sourcing semantics. `reloadCurrentState`, `offerResubmit`, and `setFieldError` below represent application-owned actions, not Arc APIs. ```typescript import { ValidationResultReason } from '@cratis/arc/validation'; const result = await command.execute(); const conflict = result.validationResults.find( (_) => _.reason === ValidationResultReason.ConcurrencyViolation, ); if (conflict) { if (conflict.reasonDetail === 'ReadChanged' || conflict.reasonDetail === 'ReadIncomplete') { // Keep the user's input. Nothing from this command was written. await reloadCurrentState(); return offerResubmit(); } // Other concurrency violations also need fresh state and a deliberate retry. await reloadCurrentState(); return offerResubmit(); } ``` `ReadChanged` is retryable: a matching event arrived after the decision read. `ReadIncomplete` is also retryable: the read could not finish after the fold’s retries. Keep the draft, re-read, and let the user review fresh state before resubmitting; do not clear the form or label the input invalid. Arc and Chronicle do not re-run the command. A `ReadChanged` result’s `state` includes `{ readModel, key }`. When Chronicle reports the event that changed the read, the `state` also has `conflictingEventTypeId`, `conflictingEventType` and `conflictingEventSequenceNumber`. There is no TypeScript constant for these detail strings. ### Telling one constraint from another `reason` says a constraint on the event store rejected the append; `reasonDetail` says **which** one. Two uniqueness constraints on the same command produce the same `reason`, so branching on it alone cannot pick the copy that belongs to each — and the message is developer text you should not be matching on either. Branch on the constraint name: ```typescript const result = await command.execute(); const rejectedBy = (constraint: string) => result.validationResults.some( (_) => _.reason === ValidationResultReason.ConstraintViolation && _.reasonDetail === constraint, ); if (rejectedBy('UniqueOrganizationNumber')) { return setFieldError( 'organizationNumber', 'That organization number is already registered.', ); } ``` The value is the constraint’s own name as Chronicle reports it on the violation — the same name a backend spec asserts with `ShouldHaveConstraintViolationFor`. Treat reason values as extensible `reason` is an open set, not an enum — Arc, Chronicle and your own code can all mint values. Accept unfamiliar values without an exhaustive enum assumption. Decide explicitly whether your application recognizes their messages as safe user-facing copy; otherwise use a generic fallback. Only `rule` means the message is yours to show. Everything else is Cratis-authored developer text: it is in English, it is not localized, and it describes framework state rather than the user’s situation. Map those to your own copy. ### Exception Details Access exception information when `hasExceptions` is `true`: ```typescript const result = await command.execute(); if (result.hasExceptions) { // Array of exception messages result.exceptionMessages.forEach((msg) => { console.error('Exception:', msg); }); // Stack trace, when the server exposes exception detail console.error('Stack trace:', result.exceptionStackTrace); } ``` For model-bound commands, exception detail is exposed only when `ArcOptions.ExposeExceptionDetails` is `true`, which is the default in Development only. Elsewhere the server replaces the messages with a generic message, leaves `exceptionStackTrace` empty, and logs the full detail server-side; use `correlationId` to find it in the logs. Controller-based commands currently return exception messages and stack traces without that redaction, so do not rely on it for controller endpoints. See the [command result envelope](/arc/http-contract/#command-result-envelope). ## Chaining Callbacks `CommandResult` supports a fluent API for handling different outcomes: ```typescript const result = await command.execute(); result .onSuccess((response) => { console.log('Success!', response); }) .onFailed((commandResult) => { console.log('Failed:', commandResult); }) .onUnauthorized(() => { console.log('Not authorized'); }) .onValidationFailure((validationResults) => { console.log('Validation errors:', validationResults); }) .onException((messages, stackTrace) => { console.error('Exception:', messages, stackTrace); }); ``` Each callback method returns the `CommandResult` instance, allowing you to chain multiple handlers. ## Best Practices ### 1. Check isSuccess First Always start by checking `isSuccess` for the overall outcome: ```typescript const result = await command.execute(); if (result.isSuccess) { // Handle success } else { // Check specific failure reasons if (!result.isAuthorized) { /* ... */ } if (!result.isValid) { /* ... */ } if (result.hasExceptions) { /* ... */ } } ``` ### 2. Provide Specific Error Messages Use the specific status properties to give users meaningful feedback: ```typescript const result = await command.execute(); if (!result.isSuccess) { if (!result.isAuthorized) { showMessage('You do not have permission to perform this action.'); } else if (!result.isValid) { showValidationErrors(result.validationResults); } else if (result.hasExceptions) { showMessage('An unexpected error occurred. Please contact support.'); } } ``` ### 3. Log Exceptions for Debugging Always log exception details when `hasExceptions` is `true`: ```typescript if (result.hasExceptions) { logger.error('Command execution failed', { messages: result.exceptionMessages, stackTrace: result.exceptionStackTrace, correlationId: result.correlationId, }); } ``` ### 4. Use Correlation ID for Tracking The `correlationId` helps track command executions across the system: ```typescript console.log(`Command executed with correlation ID: ${result.correlationId}`); ``` This is especially useful for debugging and support scenarios. ## Related Topics * [Commands](/arc/frontend/core/commands/) - Core command concepts and usage * [React Commands](/arc/frontend/react/commands/) - Using commands in React components * [Validation](/arc/backend/csharp/commands/validation/) - Understanding validation rules # Command Configuration > Configure command microservice routing and API base path per command instance or once through the React Arc root. Configure a command’s service-routing header and API base path when its endpoint is behind a gateway or hosted under a path prefix. Set these values per instance for core TypeScript usage, or through the React `` provider for commands created by React hooks. ## Microservice Routing `setMicroservice(...)` selects a logical service by adding an HTTP header; it does not change the command’s route or API origin. The default service name is empty, so no routing header is added. The header name comes from `Globals.microserviceHttpHeader`, whose default is `x-cratis-microservice`. Per command: ```typescript command.setMicroservice('user-service'); ``` Your gateway or backend must interpret that header. Giving it a value does not create a service or configure backend routing. Recommended global setup: * Configure `microservice` on `` for commands and queries created by its hooks. * See [Arc Configuration](/arc/frontend/react/arc/#microservice-support). ## API Base Path `setApiBasePath(...)` supplies a path prefix joined with the command’s declared `route`. The default prefix is empty. Keep the origin separate: `setOrigin(...)` selects the API origin, while the base path selects a location beneath it. Per command: ```typescript command.setApiBasePath('/api/v1'); ``` For an existing generated command whose route is `/users/register`, a complete instance-level routing setup might be: ```typescript command.setOrigin('https://api.example.com'); command.setApiBasePath('/api/v1'); command.setMicroservice('user-service'); const result = await command.execute(); ``` This assumes the command’s required values have already been supplied. The request goes to `https://api.example.com/api/v1/users/register` and carries the service-routing header. If a generated route already contains `/api`, do not add that segment again in the base path. Core command constructors initially copy `microservice`, `apiBasePath`, and `origin` from `Globals`; setters replace those instance values. React’s `useCommand` applies the nearest provider’s values when it creates the command. Changing `Globals` later does not reconfigure an existing instance, and a manually constructed command does not read React context. Recommended global setup: * Configure `apiBasePath` on `` for consistent routing across hook-created commands. * See [Arc Configuration](/arc/frontend/react/arc/#configuration-options). ## See Also * [Command Contracts](/arc/frontend/core/commands/contracts/) * [Backend Integration](/arc/frontend/core/commands/integration/) * [Query Configuration](/arc/frontend/core/queries/configuration/) # Command Contracts > Reference for the ICommand contract in @cratis/arc that generated command proxies implement. Core command support in `@cratis/arc` is built around typed command classes generated from backend command definitions. ## ICommand The core command interface provides execution and state-management capabilities: ```typescript interface ICommand extends ICanBeConfigured { readonly route: string; readonly roles: string[]; readonly propertyDescriptors: PropertyDescriptor[]; execute( allowedSeverity?: ValidationResultSeverity, ignoreWarnings?: boolean ): Promise>; validate(): Promise>; validateClientSide(): CommandResult; clear(): void; setInitialValues(values: TCommandContent): void; setInitialValuesFromCurrentValues(): void; revertChanges(): void; readonly hasChanges: boolean; propertyChanged(property: string): void; onPropertyChanged(callback: PropertyChanged, thisArg: object): void; } ``` ## Change Tracking Commands track property changes automatically. * `hasChanges` indicates whether current values differ from the baseline. * `setInitialValues()` sets an explicit baseline. * `setInitialValuesFromCurrentValues()` currently snapshots truthy values only. Completed server executions call it even on unsuccessful results; see [baseline limitations](/arc/frontend/react/commands/data-binding/#execution-baseline-limitations). * `revertChanges()` restores baseline values. * `validate()` can return a local validation failure before reaching the server. When reached, server preflight runs filters without executing the handler; `validateClientSide()` never calls the server. ## Property Change Notifications Commands expose property-change callbacks for reactive flows: ```typescript // Illustrative fragment: retain this owner while notifications are needed. const owner = { onChanged(property: string) { console.log(`Property ${property} changed`); } }; command.onPropertyChanged(owner.onChanged, owner); ``` The callback and its receiver are weakly referenced. Supply the required receiver and retain both it and the callback for the subscription’s intended lifetime; an otherwise unreferenced inline function is not a durable listener. ## See Also * [Validation And Results](/arc/frontend/core/commands/validation-and-results/) * [Configuration](/arc/frontend/core/commands/configuration/) * [React Commands](/arc/frontend/react/commands/) # Backend Integration > How core TypeScript commands map to controller-based and model-bound backend command endpoints through generated proxies. Core commands are designed to align with backend command endpoints and generated proxies. ## Backend Command Styles Arc supports both backend styles: * [Controller-based Commands](/arc/backend/csharp/commands/controller-based/) * [Model-bound Commands](/arc/backend/csharp/commands/model-bound/) Both styles produce strongly typed frontend command proxies. ## Proxy Generation Benefits * Compile-time type safety * IDE IntelliSense and navigation * Automatic regeneration when backend contracts change * No manual HTTP request wiring For setup and configuration, see [Backend Proxy Generation](/arc/backend/csharp/proxy-generation/). ## React Layer For higher-level usage patterns in React components, see [React Commands](/arc/frontend/react/commands/). # Command validation > Obtain early command feedback without running the handler, while preserving server authorization and filter responsibilities. Use preflight validation to give feedback before submission. It does not execute `Handle()`, reserve state, or guarantee that a later execution will succeed. ## How it works `command.validate()` first runs the configured client validator and required-property checks. A local failure returns a `CommandResult` **without a network request**. Otherwise it calls the command’s validation endpoint. The backend preflight path resolves the handler, builds command context, and runs command filters, then filters validation results. It skips handler execution and handler-argument resolution, including the normal `Provide()` path. Filters/validators are still application code: they can access services, throw, or have side effects unless you design them not to. `command.validateClientSide()` performs only local checks and returns synchronously. Required-property presence checks reject null/undefined, not automatically empty strings, false checkboxes, invalid email syntax, or numeric ranges. Those require actual rules. ## Basic usage This illustrative helper accepts an already configured and populated generated command: ```typescript import { ICommand } from '@cratis/arc/commands'; export async function inspectCommand(command: ICommand) { const result = await command.validate(); if (!result.isValid) { console.log(result.validationResults); } else if (!result.isAuthorized) { console.log('Not authorized'); } else if (result.hasExceptions) { console.log('Validation could not complete'); } return result; } ``` Both validation and execution return [CommandResult](/arc/frontend/core/commands/command-result/). `correlationId` is a Fundamentals `Guid` in the client, validation severity is a numeric enum, and errors are in `validationResults`, not a `validationErrors` object. Validation does not produce a handler response. See the complete [validation-result shape](/arc/frontend/core/validation/results/). ## Security considerations * A locally invalid result does not prove server authorization ran. Never grant access based on a preflight status flag alone. * When reached, the server runs its configured authorization/validation filters. An upstream authentication challenge may return before the Arc result envelope exists; do not assume every denial is a parseable 401/403 result. * Skipping the handler is not a confidentiality guarantee. Validators, filters, custom state, and exception messages must not leak private data. * Preflight is subject to races. `execute()` validates again and its final result is authoritative. * Warning confirmation requires explicit severity handling; plain preflight is not guaranteed to return server warnings. See [severity filtering](/arc/frontend/core/validation/severity-filtering/). ## React usage Prefer [CommandForm](/arc/frontend/react/command-form/) for form-managed lifecycle and feedback. For raw hooks, debounce server checks, use an edit revision/current field values rather than `hasChanges`, and discard stale responses. See [React command validation](/arc/frontend/react/commands/validation/). For endpoint details, continue with [backend command validation](/arc/backend/csharp/commands/command-validation/) and [command filters](/arc/backend/csharp/commands/command-filters/). # Validation and results > Inspect command validation, response payloads, and frontend failure categories. Commands return `CommandResult` and perform validation before execution. ## Client-side validation Validation metadata is generated from backend FluentValidation rules by the proxy generator. ```typescript const command = new CreateUserCommand(); command.email = ''; command.age = 15; const result = await command.execute(); // result.isValid === false // result.validationResults contains validation errors ``` For deeper validation behavior, see [Validation](/arc/frontend/core/validation/) and [Validation](/arc/frontend/core/commands/validation/). ## Result shape A command result includes status and diagnostics such as: * Success/failure status * Validation details * Response payload (when present) * Exception details For full `CommandResult` details, see [Command Result](/arc/frontend/core/commands/command-result/). ## Error categories Typical failure categories include: * Validation errors * Network and timeout failures * HTTP status failures * Domain/application exceptions ## See also * [Command Result](/arc/frontend/core/commands/command-result/) * [Validation](/arc/frontend/core/commands/validation/) * [React Commands](/arc/frontend/react/commands/) # Identity > How the frontend gets the current identity from the /.cratis/me endpoint, keeps it in memory, and exposes it through the core identity API. The frontend gets the current identity from the `/.cratis/me` endpoint. The backend derives that identity from the authenticated request, so what the frontend sees is what the server knows about the caller, not anything the browser stored. Only when the server has no such endpoint does the frontend fall back to a cookie; see [upgrading from the identity cookie](#upgrading-from-the-identity-cookie). Configure authentication on the host; identity lookup is not itself a login mechanism. > Important note: Since local development is not configured with the identity provider, but you still need a way to test that both the backend and the frontend deals with the identity in the correct way. This can be achieved by creating the correct token and injecting it as request headers using a browser extension. Read more [about generating principals](/arc/backend/csharp/development/generating-principal/). ## Identity provider Identity is intended for read-only UI consumption through `IdentityProvider`. The identity is held in the page’s memory, where anyone with the browser’s developer tools can change it, so it is not proof of anything. Every protected backend operation must authorize the actual credential independently. The `IdentityProvider` provides functionality for getting the current identity. ```typescript import { IdentityProvider } from '@cratis/arc/identity'; const identity = await IdentityProvider.getCurrent(); console.log(`Hello '${identity.name}'`); ``` > Note that the `getCurrent()` method is an asynchronous operation that returns a promise. The first call asks `/.cratis/me` for the identity. The answer is kept in memory for the page, so later calls return it without asking the server again. When the server does not resolve an identity, for example because the caller is not signed in, `getCurrent()` returns an identity with `isSet` set to `false`, and that answer is not kept: the next call asks the server again. ### Signing out `IdentityProvider.clearCache()` forgets the identity kept in memory, so the next `getCurrent()` asks the server again, and expires a `.cratis-identity` cookie left by an earlier version. It does not revoke sessions or remove authentication tokens or cookies. Real sign-out must complete through your authentication system before reconnecting anonymously. `IdentityProvider.clearIdentityCookie()` is deprecated and now does the same as `clearCache()`. ## Details Part of the identity can hold details that are beyond what the identity provider provides. These details are application specific and something that your application or ingress should be responsible for filling out. Details can be considered optional, as that might not be a requirement for your application. The `getCurrent()` method takes a generic parameter that describes the expected details object. It does not guarantee the backend supplies one. Guard absent details or choose an explicit display fallback: ```typescript import { IdentityProvider } from '@cratis/arc/identity'; type IdentityDetails = { department: string, age: number }; const identity = await IdentityProvider.getCurrent(); const department = identity.details?.department ?? 'Unknown department'; console.log(`Hello '${identity.name}' from '${department}'`); ``` Note The `` **type parameter** only tells TypeScript what shape to expect at compile time - it has no effect at runtime, because a type is erased before the code ever runs. `identity.details` above is still the raw JSON object the server sent. Deserialization into a real class - constructing `Guid`, `DateOnly`, or other complex types with their methods and behavior instead of plain JSON - only happens when you additionally pass a class **constructor** as a runtime argument to `getCurrent()`: ```typescript import { IdentityProvider } from '@cratis/arc/identity'; import { Guid, field } from '@cratis/fundamentals'; class IdentityDetails { @field(Guid) userId!: Guid; } // The constructor argument is what deserializes - not just the type parameter. const identity = await IdentityProvider.getCurrent(IdentityDetails); console.log(identity.details.userId.toString()); ``` The class also needs an `@field` decorator on every property you want populated - a class with none of them cannot be deserialized into, and the raw payload is passed through unchanged instead of being silently blanked. ## IIdentity The return type coming from `getCurrent()` looks like the following: | Name | Type | Description | | --- | --- | --- | | id | string | The unique identifier from the identity provider | | name | string | The user name | | roles | string\[] | Array of roles the identity is in | | details | `TDetails` | Application-specific details, default generic type `object`; data may be absent at runtime | | isInRole | (role: string) => boolean | Method to check if the identity is in a specific role | ## Role checking The identity includes information about the roles assigned to the user. You can check if a user is in a specific role using the `isInRole()` method: ```typescript import { IdentityProvider } from '@cratis/arc/identity'; const identity = await IdentityProvider.getCurrent(); if (identity.isInRole('Admin')) { console.log('User is an admin'); } ``` You can also access the roles array directly: ```typescript import { IdentityProvider } from '@cratis/arc/identity'; const identity = await IdentityProvider.getCurrent(); console.log(`User roles: ${identity.roles.join(', ')}`); ``` ## Refresh In some scenarios you might need to refresh the identity. Typically if the user has been granted more access or details has been updated. Rather than having your user log out and back in again, you can issue a refresh. Refresh always calls `/.cratis/me` for the identity and details, and replaces what is kept in memory, so later calls to `getCurrent()` return the refreshed identity. It does not renew or revoke authentication credentials. To refresh the identity you can call the `refresh()` method on the identity object itself. ```typescript import { IdentityProvider } from '@cratis/arc/identity'; let identity = await IdentityProvider.getCurrent(); identity = await identity.refresh(); ``` The identity object is designed to be immutable, leading to the `refresh()` method having to return a new instance. This means that the original `identity` instance won’t be updated and you would have to replace it if you have it as a variable. ## Upgrading from the identity cookie Earlier versions of Arc wrote the identity to a JavaScript-readable `.cratis-identity` cookie, and `IdentityProvider` read it before asking `/.cratis/me`. Now `IdentityProvider` asks `/.cratis/me` first. It reads the cookie only when that endpoint answers 404, for example behind a proxy that does not route it to the application yet. When it does, it logs a warning in the browser console. That fallback will be removed in a future major version. * Code that read `.cratis-identity` from `document.cookie` must call `IdentityProvider.getCurrent()`, or `useIdentity()` in React, instead. * Replace `IdentityProvider.clearIdentityCookie()` with `IdentityProvider.clearCache()`. * Identity is no longer available before the first request completes: render a loading state until `getCurrent()` resolves. In React, `useIdentity()` reports `isLoading` for this. See [migrating from the identity cookie](/arc/backend/csharp/identity/migrating-from-the-identity-cookie/) for the full picture, including mixed frontend and backend versions. # Messaging > Publish and subscribe to typed frontend messages with Messenger, including subscription cleanup and parent-child scope propagation. `Messenger` connects frontend publishers and subscribers through typed, in-memory messages. Use it for UI coordination, such as letting a details panel react to a selection, without coupling the publisher to the panel. ## IMessenger * `IMessenger` defines `publish(...)`, `subscribe(...)`, and propagation settings. * `Messenger` implements that contract with RxJS subscriptions. * `subscribe(MessageType, handler)` returns a subscription; call `unsubscribe()` when its owner no longer needs messages. Messages are matched by their constructor, not by a string name or a matching object shape. Publish an instance of the same class passed to `subscribe`; a plain object with identical properties does not have that constructor. ```typescript import { Messenger } from '@cratis/arc/messaging'; class CustomerSelected { constructor(readonly customerId: string) {} } const messenger = new Messenger(); const subscription = messenger.subscribe(CustomerSelected, message => { console.log('Load customer details:', message.customerId); }); messenger.publish(new CustomerSelected('customer-42')); subscription.unsubscribe(); messenger.dispose(); ``` Register subscribers before publishing. This is a live message channel, not a replaying store: a later subscriber does not receive earlier selections. It also does not send messages to a backend or persist them as domain events. ## Scoped hierarchy Construct a child with `new Messenger(parent)` when a feature needs its own scope. A message is delivered locally, then follows these settings: | Setting | Default | Effect | | --- | --- | --- | | `bubbleToParent` | `false` | Allows a locally published message to travel upward. Each ancestor’s setting controls further bubbling. | | `trickleDownToChildren` | `true` | Allows delivery down the child tree. Each receiving scope controls propagation to its own children. | ### Propagation and cleanup When a child bubbles a message to a parent that trickles, sibling scopes can receive it too; the sending child is excluded from that return path. Use propagation deliberately rather than assuming a child scope isolates every message. `dispose()` detaches a messenger from its parent. It does not replace subscription cleanup: keep and unsubscribe subscriptions when their handlers are no longer needed. ## React usage React applications use the nearest messenger from context. `useOnMessage` handles subscription cleanup on unmount; `useMessenger` exposes the messenger for publishing. See [React Messaging](/arc/frontend/react/messaging/) for component examples and scope configuration. ## See also * [Core TypeScript](/arc/frontend/core/) * [Arc provider configuration](/arc/frontend/react/arc/) # Queries > Use Arc's low-level TypeScript query contracts for ordinary requests and observable subscriptions. Core queries in Arc are the low-level TypeScript and JavaScript primitives used to retrieve data from backend query endpoints. Ordinary queries use HTTP request and response semantics; observable queries subscribe to an emitting backend source. Neither requires Chronicle. This section focuses on contracts and runtime behavior in `@cratis/arc`. For React hook-based usage, see [React Queries](/arc/frontend/react/queries/). ## Capabilities | Capability | What it covers | Learn more | | --- | --- | --- | | Query contracts | `IQuery`, `IQueryFor`, sorting, paging, and typed execution | [Query Contracts](/arc/frontend/core/queries/contracts/) | | Runtime configuration | Microservice routing, API base path, and observable transport mode | [Configuration](/arc/frontend/core/queries/configuration/) | | Validation and behavior | Client-side validation, request behavior, and error categories | [Validation And Behavior](/arc/frontend/core/queries/validation-and-behavior/) | | Backend integration | Controller-based/model-bound mapping and proxy generation | [Backend Integration](/arc/frontend/core/queries/integration/) | ## Related documentation * [Validation](/arc/frontend/core/validation/) * [Backend Queries](/arc/backend/csharp/queries/) * [React Queries](/arc/frontend/react/queries/) # Query Configuration > Configure query microservice routing, API base path, observable transport, cache retention, and HTTP method, with or without React. Queries can be configured for service routing and endpoint behavior in both React and non-React environments. ## Microservice Routing Per query: ```typescript query.setMicroservice('inventory-service'); ``` In React applications, configure this globally with ``. ## API Base Path Per query: ```typescript query.setApiBasePath('/api/v1'); ``` In React applications, set this globally with ``. ## Observable Query Transport (`queryDirectMode`) By default, observable queries use centralized hub endpoints. Set `Globals.queryDirectMode = true` to bypass the hub and connect directly per query. ```typescript import { Globals } from '@cratis/arc'; Globals.queryDirectMode = true; ``` | Value | Description | | --- | --- | | `false` (default) | Route through `/.cratis/queries/sse` or `/.cratis/queries/ws`. | | `true` | Connect directly to each query URL using the separately selected SSE or WebSocket transport. | In React, this is usually configured through ``. See [React query integration](/arc/frontend/react/queries/). ## Query Cache Retention (`queryCacheRetentionMs`) Controls how long (in milliseconds) the query cache keeps an entry alive after the last subscriber releases it. The default is `30 000` ms (30 seconds). ```typescript import { Globals } from '@cratis/arc'; // Keep cached data for 60 seconds after the last subscriber unmounts. Globals.queryCacheRetentionMs = 60_000; // Schedule zero-delay cleanup after the last subscriber releases the entry. Globals.queryCacheRetentionMs = 0; ``` In React applications, set this through `` instead of modifying `Globals` directly. See [React query configuration](/arc/frontend/react/queries/configuration/). ## HTTP query method Ordinary query method selection (`Get`, `Query`, `Auto`) is separate from observable transport selection. See [using the HTTP QUERY method](/arc/backend/csharp/queries/using-the-http-query-method/) for global, per-query, and resolver configuration. Native streaming handshakes cannot attach arbitrary callback headers; see [transport credentials](/arc/frontend/react/arc/#http-headers-callback). React bindings also mutate global settings, so nested providers are not independent authenticated hubs. ## See Also * [Observable Queries](/arc/frontend/react/queries/observable-queries/) * [Observable Query Multiplexing](/arc/frontend/react/queries/observable-query-multiplexing/) # Query Contracts > Reference for the IQuery, IQueryFor, and observable query contracts in @cratis/arc that generated query proxies implement. Core query support in `@cratis/arc` is built on typed query classes and generated proxies. ## IQuery The base query interface holds cross-cutting query concerns: ```typescript interface IQuery extends ICanBeConfigured { setHttpMethod(method: QueryHttpMethod): void; get sorting(): Sorting; set sorting(value: Sorting); get paging(): Paging; set paging(value: Paging); } ``` `setHttpMethod` overrides the global `Globals.queryHttpMethod` for one query instance. `QueryHttpMethod` is `Get` (the default), `Query` (RFC QUERY with a JSON body), or `Auto` (try QUERY, fall back to GET for the rest of the session). See [using the HTTP QUERY method](/arc/backend/csharp/queries/using-the-http-query-method/) before sending arguments that must stay out of URLs. ## IQueryFor `IQueryFor` adds route, typed parameters, default value, and execution: ```typescript interface IQueryFor extends IQuery, IHaveParameters { readonly route: string; readonly requiredRequestParameters: string[]; readonly defaultValue: TDataType; readonly roles: string[]; get parameters(): TParameters | undefined; set parameters(value: TParameters); perform(args?: TParameters): Promise>; } ``` ## Built-in Concerns Query contracts include: * Typed parameters and responses * Required route/request parameter metadata * Sorting and paging metadata * Required roles for UI decisions * Default values for predictable initialization * Execution through `perform()` ## See Also * [Validation And Behavior](/arc/frontend/core/queries/validation-and-behavior/) * [React Queries](/arc/frontend/react/queries/) # Backend Integration > Connect core TypeScript queries to controller-based and model-bound Arc endpoints through generated proxies and typed query results. Generated query proxies connect TypeScript callers to Arc backend query endpoints without handwritten request URLs or response deserialization. Use the proxy that matches the backend query declaration, then configure its destination and supply its typed arguments. ## Backend Query Styles Arc supports both backend styles: * [Controller-based Queries](/arc/backend/csharp/queries/controller-based/) use ASP.NET Core controllers and routing. * [Model-bound Queries](/arc/backend/csharp/queries/model-bound/) put discoverable query methods on a read model. The backend chooses the route, parameters, and returned model. The proxy generator reads the compiled application and emits the corresponding route, parameter descriptors, model type, and one-shot or observable client. These are endpoint contracts, not a requirement to use Chronicle or event sourcing. ## Calling a generated query For example, Arc’s query-showcase sample declares a model-bound `ById(int id)` query returning one `ShowcaseItem`. Its generated `ById` proxy extends `QueryFor` and declares the route `/api/features/query-showcase/by-id`. With those generated files available to your frontend: ```typescript import { ById } from './Features/QueryShowcase/ById'; const query = new ById(); query.setOrigin('https://api.example.com'); const result = await query.perform({ id: 42 }); if (result.isSuccess) { console.log(result.data.name); } ``` The import path depends on your configured proxy output folder. `perform(...)` returns a query-result envelope, not the model alone: inspect success, authorization, validation, and exception information before using the data. Required request arguments are checked before a request is sent. Ordinary queries default to HTTP GET; the core request builder places arguments not consumed by route placeholders in the query string. ## Proxy Generation Benefits Regenerate proxies when backend routes, argument types, or result shapes change; do not repair generated files by hand. Configure `apiBasePath` only for an additional deployment prefix, not a duplicate of the generated route. If a gateway selects a service through a header, set `microservice` separately. For setup and configuration, see [Backend Proxy Generation](/arc/backend/csharp/proxy-generation/). Observable queries use the streaming client contract instead of a one-shot `perform` call; see [Observable Queries](/arc/frontend/react/queries/observable-queries/). ## React Layer React hooks provide lifecycle and state handling on top of the same contracts. See [React Queries](/arc/frontend/react/queries/) rather than starting requests directly during component rendering. ## See also * [Query Contracts](/arc/frontend/core/queries/contracts/) * [Query Configuration](/arc/frontend/core/queries/configuration/) # Validation and behavior > Validate query arguments and understand paging, cancellation, readiness, and frontend query failures. Core queries validate input and provide predictable execution behavior. ## Client-side validation Validation metadata is generated from backend FluentValidation rules through the proxy generator. ```typescript const query = new SearchUsersQuery(); query.parameters = { searchTerm: 'ab', minAge: -5 }; const result = await query.perform(); // result.isValid === false // result.validationResults contains validation errors ``` Observable queries validate the same way. `perform()` returns an invalid result, and `subscribe()` delivers one to your callback instead of opening a connection: ```typescript const query = new ObserveUsers(); query.subscribe(result => { // result.isValid === false when the arguments were rejected, // which is distinct from a valid result that simply has no data yet }, { minAge: -5 }); ``` Missing arguments behave differently from arguments that break a rule. When a required argument is absent and no generated client rule (such as `notEmpty()`) has already rejected it, `subscribe()` opens no connection and delivers the query’s default value as an empty result whose `isSuccess` and `isValid` are both `true`, so the screen cannot tell “no arguments yet” from “no data”. Gate the subscription with `ObservableQueryWhen` so it only starts once the arguments exist: In a React component, use the generated wrapper (illustrative fragment): ```tsx const [books] = BooksForAuthor.when(!!authorId).use({ authorId }); ``` `ObservableQueryWhen` is a class returned by `.when()`, not a JSX component. Supply a correctly typed argument/default even while disabled; the condition suppresses automatic subscription, not all query/cache creation. See [conditional queries](/arc/frontend/react/queries/conditional-queries/). Client-side validation is a convenience, not a gate — every rule it applies is also enforced by the server, so calling an endpoint directly gains nothing. Server rejections report member names the same way the client does: camelCased, and attributed to the field rather than to a concept’s inner value. For an HTTP query request, a server validation rejection returns HTTP 400 with a query result whose `isValid` and `isSuccess` are `false` and whose `validationResults` contain the member names and messages. `perform()` preserves that result, and React query hooks expose those same fields. Observable subscriptions to model-bound queries deliver one invalid result through the callback and then end that subscription, over either WebSocket or SSE, in direct or hub mode. A controller-based observable query over a direct WebSocket still fails the connection instead ([#3230](https://github.com/Cratis/Arc/issues/3230)). For general validation docs, see [Validation](/arc/frontend/core/validation/). ## Sorting and paging Queries include native sorting and paging primitives via `Sorting` and `Paging`. For React usage patterns and generated hooks, see [Paging](/arc/frontend/react/queries/paging/). ## Request cancellation When a newer request supersedes an active one, Arc cancels stale work to reduce race conditions and unnecessary processing. ## Error categories Typical query failure categories include: * Parameter validation errors * Network failures * Timeouts and cancellations * Server exceptions ## See also * [Query Contracts](/arc/frontend/core/queries/contracts/) * [React Queries](/arc/frontend/react/queries/) # Validation > Overview of Arc's TypeScript validation primitives and generated rules that run before command and query requests. Core validation in Arc provides shared TypeScript/JavaScript primitives and generated rules that run before command and query requests. This page is an overview of validation capabilities. Use the pages below for specific implementation details. ## Capabilities | Capability | What It Covers | Learn More | | --- | --- | --- | | Fluent rules API | Programmatic rule definition, built-in rules, and custom messages | [Rules And Fluent API](/arc/frontend/core/validation/rules/) | | Command/query integration | Automatic pre-flight validation behavior for commands and queries | [Command And Query Integration](/arc/frontend/core/validation/integration/) | | Validation payloads | Result shape, severities, and diagnostics | [Validation Results](/arc/frontend/core/validation/results/) | | Severity-based execution gating | Warning/information filtering and confirmation flows | [Severity Filtering](/arc/frontend/core/validation/severity-filtering/) | ## Related Topics * [Core Commands](/arc/frontend/core/commands/) * [Core Queries](/arc/frontend/core/queries/) * [Backend Command Validation](/arc/backend/csharp/commands/validation/) * [Backend Query Validation](/arc/backend/csharp/queries/validation/) * [Proxy Generation Validation](/arc/backend/csharp/proxy-generation/validation/) # Command and query integration > Understand how generated frontend commands and queries apply backend validation metadata. Validation runs automatically before command execution and query performance. ## Commands ```typescript const command = new CreateUserCommand(); command.email = ''; command.age = 15; const result = await command.execute(); // result.isValid === false ``` ## Queries ```typescript const query = new SearchUsersQuery(); query.parameters = { searchTerm: 'ab', minAge: -5 }; const result = await query.perform(); // result.isValid === false ``` ## Backend-governed validation Rules are defined on the backend and extracted by proxy generation: * Single source of truth for validation rules * Consistent frontend/backend behavior * Type-safe generated validators See [Backend Command Validation](/arc/backend/csharp/commands/validation/) and [Backend Query Validation](/arc/backend/csharp/queries/validation/). ## Related * [Rules And Fluent API](/arc/frontend/core/validation/rules/) * [Validation Results](/arc/frontend/core/validation/results/) * [Severity Filtering](/arc/frontend/core/validation/severity-filtering/) # Validation Results > Reference for the validation-result shape that commands and queries return, including severity and member paths. Validation failures are returned in command/query results using a shared validation-result shape. ## Result Item Shape ```typescript interface ValidationResult { severity: ValidationResultSeverity; message: string; members: string[]; state: unknown; reason: string; reasonDetail?: string; } ``` Example: ```typescript { severity: ValidationResultSeverity.Error, message: 'Email address is required', members: ['email'], state: null, reason: 'rule' } ``` This is a structural reference fragment, not a replacement for importing `ValidationResult` from `@cratis/arc/validation`. The runtime class defaults `reason` to `rule`. `reason` is an open string set. Known values include `rule`, `validatorFailed`, `dependencyUnavailable`, `malformedRequest`, and `frameworkFailure`. Optional Chronicle integration can also produce `concurrencyViolation` and `constraintViolation`; these do not imply that standalone Arc uses an event store. `reasonDetail` identifies a finer cause where supplied. Do not match message prose to distinguish failures or reject unfamiliar reason strings. `frameworkFailure` means the framework could not fulfill a command contract or complete its effects. It is framework-composed, and its message is not meant for end users. Show authored rule messages only when suitable for your audience; map framework diagnostics to your own user-facing copy. `state` is application-authored metadata and must not contain secrets. ## Severity Levels * `Error` * `Warning` * `Information` * `Unknown` For command execution filtering by severity, see [Severity Filtering](/arc/frontend/core/validation/severity-filtering/). ## Related * [Command Result](/arc/frontend/core/commands/command-result/) * [Validation](/arc/frontend/core/commands/validation/) # Rules and fluent API > Define frontend validation rules and see which FluentValidation rules Arc maps into TypeScript. Arc validation for frontend core provides a fluent API that mirrors common FluentValidation patterns. ## Programmatic rule definition Use `ruleFor()` with a chainable API: ```typescript class CreateUserCommandValidator extends CommandValidator { constructor() { super(); this.ruleFor(c => c.email).notEmpty().emailAddress(); this.ruleFor(c => c.age).greaterThanOrEqual(18); this.ruleFor(c => c.name).minLength(2).maxLength(50); } } ``` ## Built-in rules * `notEmpty()`, `notNull()` * `minLength()`, `maxLength()`, `length()` * `emailAddress()` * `matches(regex)` * `greaterThan()`, `greaterThanOrEqual()`, `lessThan()`, `lessThanOrEqual()` For strings, `notEmpty()` rejects empty text and text made entirely of Unicode `White_Space` characters, matching .NET `char.IsWhiteSpace`. This includes U+0085 (NEXT LINE), but not U+FEFF (BYTE ORDER MARK). ## Custom messages Use `withMessage()` to override defaults: ```typescript this.ruleFor(c => c.email) .notEmpty() .withMessage('Email address is required'); ``` ## Supported FluentValidation mappings | FluentValidation | TypeScript | | --- | --- | | `NotEmpty()` | `notEmpty()` | | `NotNull()` | `notNull()` | | `EmailAddress()` | `emailAddress()` | | `MinimumLength(n)` | `minLength(n)` | | `MaximumLength(n)` | `maxLength(n)` | | `Length(min, max)` | `length(min, max)` | | `Matches(regex)` | `matches(regex)` | | `GreaterThan(n)` | `greaterThan(n)` | | `GreaterThanOrEqualTo(n)` | `greaterThanOrEqual(n)` | | `LessThan(n)` | `lessThan(n)` | | `LessThanOrEqualTo(n)` | `lessThanOrEqual(n)` | ## Note Complex custom predicates such as `.Must()` are not supported for generated client-side execution. # Validation severity filtering > Choose which validation severities block execution and avoid executing warning-only commands before confirmation. Warnings are useful only if the UI knows whether they block the write. Arc’s default allows warnings, so calling `execute()` and then asking for permission is too late for a warning-only command. ## Filtering logic `command.execute(allowedSeverity?, ignoreWarnings?)` applies these client rules and communicates the choice to the server: | Choice | Blocking client results | | --- | --- | | Omitted severity | Error only | | `Warning` | Error only | | `Information` | Warning and Error | | `Unknown` | Information, Warning, and Error | | `ignoreWarnings: true` | Error only; overrides the severity choice | The numeric enum is `Unknown = 0`, `Information = 1`, `Warning = 2`, `Error = 3`. Explicit severity retains results **greater than** the allowed level. Do not pass Error as an override: it can filter all client errors. Mandatory business, integrity, and security requirements must remain server-enforced and non-overridable. Authorization is a separate pipeline decision, not merely a warning. ## Warning confirmation workflow If your rule is “ask only when warnings exist,” first execute with **Information** allowed, not the default. That attempt can execute immediately if there are no warnings/errors; it is an execution attempt, not a read-only preview. When warnings block it, require a nonempty warning-only result, no exceptions, and authorized status before offering an override. This standalone helper takes an already populated command and an application-owned confirmation function. Freeze the draft while it runs so confirmation applies to the same content: ```typescript import { ICommand, CommandResult } from '@cratis/arc/commands'; import { ValidationResultSeverity } from '@cratis/arc/validation'; export async function executeWithWarningConfirmation( command: ICommand, confirm: (messages: string[]) => Promise ): Promise> { const result = await command.execute(ValidationResultSeverity.Information); const warningsOnly = !result.isSuccess && result.isAuthorized && !result.hasExceptions && result.validationResults.length > 0 && result.validationResults.every(item => item.severity === ValidationResultSeverity.Warning); if (!warningsOnly) return result; const confirmed = await confirm(result.validationResults.map(item => item.message)); if (!confirmed) return result; return command.execute(ValidationResultSeverity.Warning); } ``` A local warning can short-circuit before server authorization. The local `isAuthorized` value is therefore not proof that the server granted access; the final execution still must pass server authorization. Never interpret an empty validation array plus an authorization failure as “warnings only.” If the user must confirm **every** write, ask before the first execution, regardless of warnings. Do not use this helper as a universal preflight preview. ## Why validate is not a warning-preview replacement `validate()` can stop at client rules without contacting the server, and server preflight uses its default severity filtering. It is not guaranteed to return all warnings or prove server authorization. Validators and filters remain application code and must independently avoid side effects. See [preflight validation](/arc/frontend/core/commands/validation/). ## Server behavior and boundaries The generated endpoint reads the severity headers and the command pipeline applies its blocking-result policy. Server policies and projected client rules can differ; always inspect the final result. A successful earlier preflight or confirmed warning does not reserve state or guarantee later execution success. Use warnings only for genuinely overridable advice, not data integrity, access control, or regulatory requirements. See [backend severity filtering](/arc/backend/csharp/commands/validation-severity-filtering/) for the authoritative pipeline behavior and [command filters](/arc/backend/csharp/commands/command-filters/) for application-owned checks. # React > How Arc's React hooks and helpers turn generated command and query proxies into idiomatic React, and where to start. This is the heart of Arc on the frontend: a small set of hooks and helpers that turn your generated command and query proxies into idiomatic React. You import a proxy, call its `.use()` hook, and get typed command/query integration without a hand-written API client. Query results include loading state; raw command hooks still need explicit execution state or a form component. Start with [Commands](/arc/frontend/react/commands/) and [Queries](/arc/frontend/react/queries/); the rest of the pages here refine those two with validation, scopes, and identity. ## Working with commands | Topic | What it covers | | --- | --- | | [Commands](/arc/frontend/react/commands/) | The `.use()` hook — instantiate, bind, validate, and execute a command from a component. | | [Command Validation](/arc/frontend/react/commands/validation/) | Pre-flight validation: catch invalid input before the request leaves the browser. | | [Command Scopes](/arc/frontend/react/commands/scope/) | A scope that captures changes, validation, and errors across several commands in one UI. | ## Working with queries | Topic | What it covers | | --- | --- | | [Queries](/arc/frontend/react/queries/) | Reading data with `.use()` — including live, observable queries that re-render on change. | ## Around the edges | Topic | What it covers | | --- | --- | | [Configure Arc](/arc/frontend/react/arc/) | Point the React app at your backend and set transport, headers, and identity. | | [Identity](/arc/frontend/react/identity/) | Who the user is, and what they’re allowed to see and do. | | [Dialogs](/arc/frontend/react/dialogs/) | Consistent dialog handling for command and data-entry flows. | | [Proxy generation (C#)](/arc/backend/csharp/proxy-generation/) | Generate the typed clients used in these C# examples. For other backends, see [JVM proxies](/arc/backend/kotlin/guides/typescript-proxies/) or the [TypeScript source preview’s generator](/arc/backend/typescript/proxy-generation/). | | [Storybook](/arc/frontend/react/storybook/) | The Storybook for the components Arc exposes. | | [Story Components](/arc/frontend/react/stories/) | Building good-looking, consistent stories. | Prefer a structured, testable approach for complex screens? See [MVVM with React](/arc/frontend/react/mvvm/). # Arc provider > Configure React bindings, understand provider boundaries, and reconnect observable queries after authentication changes. Mount `` above your command and query consumers so they share runtime configuration. It initializes bindings; you do not need a generated startup module. ```tsx import { Arc } from '@cratis/arc.react'; export const App = () =>
Your application
; ``` ## Context nesting Arc supplies these contexts, from outside to inside: ``` ArcContext MessengerScopeContext QueryInstanceCacheContext IdentityProvider CommandScope Your components ``` `QueryScope` is not included: add it where you need a query-loading callback. An optional Components provider does not replace Arc. ## Configuration options | Prop | Type | Default / purpose | | --- | --- | --- | | `microservice` | `string` | `''`; service identifier for a configured ingress | | `development` | `boolean` | `false` | | `origin` | `string` | `''`; current origin | | `basePath` | `string` | **Deprecated.** `''`; retained in `ArcContext` for compatibility, but does not configure requests or routing | | `apiBasePath` | `string` | `''`; prefix for Arc requests; do not duplicate the proxy route’s `/api` | | `httpHeadersCallback` | `() => HeadersInit` | Extra headers on fetch paths, not native streaming handshakes | | `detailsType` | `Constructor` | Optional constructor for the identity’s application-specific details type, enabling type-safe deserialization — see [Type-safe identity with complex types](/arc/frontend/react/identity/#type-safe-identity-with-complex-types) | | `eventSourceFactory` | `(url: string) => EventSource` | Override the SSE client constructor | | `queryTransportMethod` | `QueryTransportMethod` | `ServerSentEvents` | | `queryDirectMode` | `boolean` | `false`; use shared hubs | | `queryConnectionCount` | `number` | `1`; hub connection slots (SSE capped at four) | | `observableQueryTransferMode` | `ObservableQueryTransferMode` | `Delta`; see [change streams](/arc/frontend/react/queries/change-stream/) | | `queryCacheRetentionMs` | `number` | `30000`; retention after the last cache consumer releases an entry | `observableQueryDiagnostics` is a service exposed **on `ArcContext`**, not an `` prop. See [diagnostics](/arc/frontend/react/queries/observable-query-diagnostics/). ## Hosting under a path prefix If your app is served at `/workbench`, set `` so Arc’s command, query, identity, and observable-query requests reach that prefix. Configure your application’s router separately with `basename="/workbench"` so client-side links and routes resolve under it. Arc does not configure the router, and the deprecated `basePath` prop does neither job. Keep the generated proxy’s route (normally beginning with `/api`) out of `apiBasePath`; for example, `/workbench` plus `/api/orders` produces `/workbench/api/orders`. The backend must accept the same prefix. For ASP.NET Core, see [hosting with `UsePathBase`](/arc/backend/csharp/asp-net-core/configuration/#hosting-under-a-path-prefix). For the lightweight host, see [static files and fallback](/arc/backend/csharp/core/static-files/#hosting-under-a-path-prefix). ## Microservice support `microservice` supplies routing metadata in request headers or transport messages/query parameters. Your ingress must understand that metadata; Arc cannot configure the gateway for you. ### Multiple microservices in one frontend Nested providers create separate context values, caches, and messengers, and hook-created commands/queries receive some per-instance settings. However, bindings also write **module-global** transport/header/identity configuration. Multiplexed observables use one shared multiplexer; switching its service/configuration key disposes the previous one. Do not use nested `` providers as a guarantee of independent authenticated hubs or isolated identity contexts. Defaults are not inherited either: an inner provider with no `apiBasePath` uses `''`, not its parent’s value. Simultaneous services with different origins or credentials need an independently verified integration. Isolated multiplexer ownership and global configuration removal are runtime follow-up candidates, not behavior promised by the current provider. ## HTTP headers callback Use `httpHeadersCallback` for dynamic headers on fetch requests. The callback does not change browser restrictions on streaming constructors: | Request path | Callback headers | Credentials | | --- | --- | --- | | Command, ordinary query, identity fetch | Yes | Browser-managed cookies according to fetch/origin policy; callback may supply Authorization | | SSE hub subscribe/unsubscribe POST | Yes, from global bindings | Separate fetch requests using the default credential mode, not `credentials: 'include'`; a credentialed SSE factory does not configure these POSTs | | Native EventSource handshake, direct or hub | No | Browser-managed cookies; cross-origin credentialed SSE needs an appropriate factory and server CORS | | Native WebSocket handshake, direct or hub | No | Browser-managed cookies under browser policy; no arbitrary Authorization header option | Do not attempt to set `Cookie` through the callback: it is a forbidden browser request header. A custom EventSource factory receives the URL only; any extra authentication capability belongs to that implementation and must be configured explicitly. See [query configuration](/arc/frontend/react/queries/configuration/#custom-eventsource-factory). ## Reconnecting queries After your authentication system completes login or logout, call `reconnectQueries()` to replace observable connections. New handshakes use whatever credentials the browser actually has at that point; callback bearer headers still do not become native streaming headers. This illustrative control accepts an application-owned sign-out function. That function must complete real sign-out, including the server/identity-provider session where applicable: ```tsx import { useContext } from 'react'; import { ArcContext } from '@cratis/arc.react'; import { useIdentity } from '@cratis/arc.react/identity'; export function SignOut({ signOut }: { signOut: () => Promise }) { const arc = useContext(ArcContext); const identity = useIdentity(); async function logout() { await signOut(); identity.clearIdentity(); arc.reconnectQueries?.(); } return ; } ``` Clearing identity removes the UI cache cookie, **not** authentication cookies, tokens, or server sessions. Reconnection is not sign-out and does not guarantee an anonymous result. ### What happens internally Reconnection tears down subscriptions in this provider’s query cache without evicting their data, resets the **shared** multiplexer, and increments a query version so observable effects resubscribe. Cached data can remain visible; do not treat reconnecting as a secure data purge. The shared reset can also affect other providers. Continue with [identity](/arc/frontend/react/identity/), [query scopes](/arc/frontend/react/queries/scope/), or the [transport protocol](/arc/frontend/react/queries/observable-query-multiplexing/). # CommandForm > Build a form around one generated Arc command, then choose validation, layout, and lifecycle behavior. Editing a command should not require a second copy of its values in React state. `CommandForm` owns one generated command instance and binds its fields to that instance. Submitting the form executes the values you edited. []()[]() ## Start with a generated command Before using these examples, configure [Arc in React](/arc/frontend/react/arc/) and [proxy generation](/arc/backend/csharp/proxy-generation/getting-started/). Import a **generated command class**, not a TypeScript interface or an empty subclass of `Command`: execution needs the generated route, property descriptors, and validation metadata. The component below is a runnable frontend checkpoint in that configured application. It expects a generated `UpdateProfile` at `./commands/UpdateProfile` with `name` and `email` string properties; adapt the import to your configured output directory. Its backend endpoint and rules are shown in [Validation](/arc/frontend/react/command-form/validation/#backend-validation). That example returns profile details to demonstrate a standalone Arc response; it does not persist them or require Chronicle. ```tsx import { CommandForm, InputTextField, useIsCommandExecuting } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; function SaveButton() { const isExecuting = useIsCommandExecuting(); return ; } export function ProfileForm() { return ( window.alert('Profile accepted')} > value={c => c.name} title="Name" placeholder="Your name" /> value={c => c.email} title="Email" type="email" /> ); } ``` Edit a field, leave it to see rule failures, then submit valid values. `SaveButton` reads the surrounding form’s execution state because it renders **inside** the form. Hooks called in `ProfileForm` itself would be outside that provider. For a toolbar or dialog footer, use [formRef and onStateChange](/arc/frontend/react/command-form/form-lifecycle/#reaching-the-form-from-a-parent). `type="email"` chooses an HTML control; it does not define an Arc email rule. The form uses `noValidate`, so browser constraint validation does not block submission. See [required fields and validation rules](/arc/frontend/react/command-form/validation/#required-fields). []() ## Props reference `command` is required; `children` accepts React nodes. The callbacks below use the generated instance type `TCommand` and the response type `TResponse` (default `object`). | Prop | Default | Contract | | --- | --- | --- | | `command` | Required | `Constructor`; use the generated command class. | | `initialValues` | Unset | `Partial` seed and change-tracking baseline. Undefined values supply nothing; changing this prop alone after mount does not repopulate the command. | | `currentValues` | Unset | Reactive `Partial` overlay. Present keys are written, including explicit `null`/`undefined` where the property type permits them; absent keys are left alone. | | `populateFromQuery` | Unset | Single-instance query constructor used to populate field baselines. | | `populateFromObservableQuery` | Unset | Observable counterpart; choose one population source. | | `populateFromQueryArgs` | Unset | Argument object for either population source. | | `validateOn` | `'blur'` | `'blur'`, `'change'`, or `'both'`; controls when interaction errors appear, not whether silent validation runs. | | `validateAllFieldsOnChange` | `false` | Display the full validation result rather than merging only the interacted field’s errors. | | `validateOnInit` | `false` | Display initialization/population validation errors. Silent validation still runs when false. | | `autoServerValidate` | `false` | Opt into pre-submit server validation; submission still validates on the server. | | `autoServerValidateThrottle` | `500` | Delay in milliseconds for the typing-triggered server-validation timer, not a global request limiter. | | `onFieldValidate` | Unset | Synchronous `(command, fieldName, oldValue, newValue) => string \| undefined`; supplies a custom field message. | | `onFieldChange` | Unset | `(command, fieldName, oldValue, newValue, validationInfo?) => void`; interaction notification, not a fresh asynchronous validation verdict. | | `onBeforeExecute` | Unset | Synchronous `(command: TCommand) => TCommand`; return the executable command instance. | | `onSuccess` | Unset | `(response: TResponse) => void`. | | `onFailed` | Unset | `(result: ICommandResult) => void`; receives the original result when execution fails. | | `onException` | Unset | `(messages: string[], stackTrace: string) => void`; receives original diagnostics when `hasExceptions` is true. | | `onUnauthorized` | Unset | `() => void`. | | `onValidationFailure` | Unset | `(validationResults: ValidationResult[]) => void`. | | `formRef` | Unset | `React.Ref` for parent execution and live state reads. | | `onStateChange` | Unset | `(state: CommandFormState) => void` for reactive parent state. | | `showTitles` | `true` | Enable built-in field titles. Custom renderers must honor the intended presentation themselves. | | `showErrors` | `true` | Show automatic field errors and form-level exception feedback, including the custom exception renderer. Does not suppress messages independently rendered by custom fields or summaries. | | `exceptionMessage` | `'An unexpected error occurred. Please try again.'` | Safe user-facing `string`; supports localization and preserves an explicitly empty string. | | `exceptionDisplayComponent` | Unset | `React.ComponentType` replacing the default exception panel; receives only `{ message: string }` and is not invoked when `showErrors` is false. | | `fieldContainerComponent` | Unset | Component receiving `FieldContainerProps`. | | `fieldDecoratorComponent` | Unset | Component receiving `FieldDecoratorProps`. | | `errorDisplayComponent` | Unset | Component receiving `ErrorDisplayProps` for field validation errors, not form-level exceptions. | | `tooltipComponent` | Unset | Component receiving `TooltipWrapperProps`. | | `errorClassName` | `'p-error'` | Default error element’s CSS class. | | `iconAddonClassName` | `'p-inputgroup-addon'` | Default icon wrapper’s CSS class. | Exception feedback uses safe text rather than raw server diagnostics. See [safe exception customization](/arc/frontend/react/command-form/customization/#safe-exception-feedback) for localization and replacement renderers, and [exception diagnostics and display](/arc/frontend/react/command-form/form-lifecycle/#exception-diagnostics-and-display) for callback and context behavior. ## Initial values Use `initialValues` for the initial seed and baseline, as in the example above; use `currentValues` for a reactive overlay. Changing `initialValues` alone after mount does not repopulate the command. See [Data loading](/arc/frontend/react/command-form/data-loading/) for precedence and population, [Validation](/arc/frontend/react/command-form/validation/) for state limitations, and [Customization](/arc/frontend/react/command-form/customization/) for renderer contracts. ## Children Fields bind through fragments, HTML elements, and custom layout components. Use simple accessors such as `c => c.name`; binding resolves a property name, not an arbitrary computed expression. `CommandForm.Column` groups columns; its responsive styling needs the CSS described in [Layouts](/arc/frontend/react/command-form/layouts/). []() ## Next steps * [Field types](/arc/frontend/react/command-form/field-types/): choose a control and its supported props. * [Validation](/arc/frontend/react/command-form/validation/): distinguish client rules, server rules, and error display. * [Working with hooks](/arc/frontend/react/command-form/hooks/): read and edit the same form instance. * [Form lifecycle](/arc/frontend/react/command-form/form-lifecycle/): handle results and external execution. * [Advanced patterns](/arc/frontend/react/command-form/advanced-patterns/): computed previews, dialogs, and autosave boundaries. # Advanced patterns > Add computed previews and external controls without creating a second command or assuming autosave guarantees. Once fields are bound, keep extensions on the same form instance. A preview reads context; a dialog footer uses the form handle. Neither needs an independently constructed command. ## Computed fields This complete component expects a generated `CreateInvoice` with numeric `quantity` and `unitPrice` properties. The total is a **display preview**, not a trusted price calculation or a persisted command field. ```tsx import { CommandForm, NumberField, useCommandInstance } from '@cratis/arc.react/commands'; import { CreateInvoice } from './commands/CreateInvoice'; function InvoicePreview() { const command = useCommandInstance(); const total = command.quantity * command.unitPrice; return Preview total: {total.toFixed(2)}; } export function InvoiceForm() { return ( value={c => c.quantity} title="Quantity" min={1} step={1} /> value={c => c.unitPrice} title="Unit price" min={0} step={0.01} /> ); } ``` The descendant rerenders after form field edits and reads their current values. Enforce valid quantities and authoritative prices in the backend; numeric input attributes and a JavaScript preview are not business validation. If you transform command values before execution, return the existing instance synchronously from [onBeforeExecute](/arc/frontend/react/command-form/form-lifecycle/#before-execute-hook). ## Integration with external libraries For a modal, page toolbar, or wizard footer, use the complete [parent-control example](/arc/frontend/react/command-form/form-lifecycle/#reaching-the-form-from-a-parent). It supplies both `formRef` (execution) and `onStateChange` (reactive busy state), and routes keyboard submission through the same confirmation gate. Keep the modal’s open/close state in its owning component. Close only after `result.isSuccess` or `onSuccess`; validation or authorization failure should leave the edited form available for correction. Use the dialog API from your installed UI package rather than an invented modal abstraction. [Arc dialogs](/arc/frontend/react/dialogs/) and an optional Components integration are separate presentation choices; neither changes the CommandForm context boundary. Load edit data with [query population](/arc/frontend/react/command-form/data-loading/#populating-a-form-from-a-query) or mount the form with resolved initial values. Passing a separate command instance as `initialValues` does not make that instance the form’s executable command, and spreading a generated command does not copy its accessor-backed properties. ## Autosave boundaries An edit revision, not `hasChanges`, is the unit of autosave work. Use the form’s `commandVersion` or actual values to reset a timer. The following diagram is an **application design outline**, not a built-in Arc autosave API: ``` Edit revision New edit resets timer Timer expires Newer revision pending after save Success with no pending revision Rejection or request failure Explicit retry or new edit Idle Waiting Saving Failed ``` Your implementation must decide how to snapshot edits, serialize requests, handle retries, preserve edits made during a save, and avoid resaving a failed revision in a loop. Run execution through the form to preserve its callbacks. Keep a last-successful revision or value snapshot outside the command when you need an accurate “saved” indicator. Current limitation: `Command.execute()` snapshots its baseline after a completed server request even when that result failed; its snapshot helper skips falsy values. It is therefore unsafe to infer persisted state solely from `hasChanges`. CommandForm also does not prevent overlapping submissions. These are runtime boundaries to account for, not behaviors a documentation example can repair. ## Check the behavior Exercise empty, invalid, valid, in-flight, and rejected states. Also test rapid edits, responses arriving out of order, unmount while loading, keyboard submit, and canceling confirmation. A successful build does not establish that the edited values—not an independent command—were submitted. ## See also * [Layouts](/arc/frontend/react/command-form/layouts/) * [Working with hooks](/arc/frontend/react/command-form/hooks/) * [Validation](/arc/frontend/react/command-form/validation/) * [Form lifecycle](/arc/frontend/react/command-form/form-lifecycle/) # Auto server validation > Surface server-only rules before submission without treating preflight as permission to execute. A name or email can pass local formatting rules and still fail a database-backed rule. Enable `autoServerValidate` to surface such server-only failures while the user fills out the form. It is optional and defaults to `false`; ordinary submission always validates on the server when local validation passes. ## Enable preflight feedback This complete component uses the generated `UpdateProfile` from the [validation example](/arc/frontend/react/command-form/validation/#backend-validation). Configure Arc and generate the proxy before using it. ```tsx import { CommandForm, InputTextField, useIsCommandExecuting } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; function SubmitButton() { const isExecuting = useIsCommandExecuting(); return ; } export function ProfileForm() { return ( value={c => c.name} title="Name" /> value={c => c.email} title="Email" type="email" /> ); } ``` After locally valid edits settle, the form calls the generated command’s `validate()` and displays returned field errors. The validation request uses the command’s resolved route with `/validate` appended; use the generated route rather than guessing a kebab-case URL. This requires an Arc command endpoint, not an arbitrary JSON validation service. ## Validation flow ``` No Yes No Yes Field edit Silent client validation No validation results? Update validity and configured error display Reset typing-validation timer command.validate Initialization or population Blur when validateOn includes blur Local checks pass? POST resolved command route /validate Apply result unless overtaken by a later applied validation ``` The typing timer is a **debounce**, despite the prop’s `Throttle` name: another edit clears the pending timer. The default is 500 ms; a value of zero removes the delay on this path. Choose a longer delay for expensive checks. The delay is **not a global request limiter**. With `autoServerValidate` enabled, initialization/population and eligible blur validation also call `validate()` and are not delayed by that timer. `validateOnInit={false}` only hides initial error display; it does not prevent the initialization check. `validateOn` controls interaction error display, while silent client validation runs on each edit. Do not promise exactly one request per typing burst or per form. ## Add a server-only rule First define an actual endpoint, such as `UpdateProfile` in [Backend validation](/arc/frontend/react/command-form/validation/#backend-validation). A standalone DTO and validator alone are not this model-bound endpoint. The following **application integration fragment** adds the directory rule beside that example’s command; the frontend form behavior below uses the shared client runtime. On the TypeScript backend, verify the projected rules against its [generator validation reference](/arc/backend/typescript/proxy-generation/validation/) rather than assuming the backend rule is emitted. It compiles alongside `UpdateProfile`; to run it, supply and register your application’s profile-directory implementation. The allowed-email check must apply your own profile/tenant policy, including allowing an unchanged email for an existing profile. * C# ```csharp using System.Threading; using System.Threading.Tasks; using Cratis.Arc.Commands; using FluentValidation; public interface IProfileDirectory { Task IsEmailAllowed(EmailAddress email, CancellationToken cancellationToken); } public class UpdateProfileValidator : CommandValidator { public UpdateProfileValidator(IProfileDirectory profiles) { RuleFor(command => command.Name) .NotEmpty().MinimumLength(3).MaximumLength(100); RuleFor(command => command.Email) .MustAsync((command, _, cancellationToken) => profiles.IsEmailAllowed(command.Email, cancellationToken)) .WithMessage("This email cannot be used for this profile."); } } ``` * Java ```java public interface ProfileDirectory { CompletionStage isEmailAllowed(String email); } public final class UpdateProfileDirectoryRules implements AsyncCommandValidator { private final ProfileDirectory profiles; public UpdateProfileDirectoryRules(ProfileDirectory profiles) { this.profiles = profiles; } @Override public Class getCommandType() { return UpdateProfile.class; } @Override public CompletionStage> validate( UpdateProfile command, CommandContext context ) { return profiles.isEmailAllowed(command.email()).thenApply(allowed -> allowed ? List.of() : List.of(ValidationResult.error( "This email cannot be used for this profile.", List.of("email")))); } } @Configuration public class UpdateProfileValidation { @Bean public CommandValidator updateProfileDirectoryRules(ProfileDirectory profiles) { return new AsyncCommandValidatorAdapter<>(new UpdateProfileDirectoryRules(profiles)); } } ``` * Kotlin ```kotlin interface ProfileDirectory { suspend fun isEmailAllowed(email: String): Boolean } @Component class UpdateProfileDirectoryRules( private val profiles: ProfileDirectory ) : CommandValidator { override val commandType: Class = UpdateProfile::class.java override suspend fun validate( command: UpdateProfile, context: CommandContext ): List = if (profiles.isEmailAllowed(command.email)) { emptyList() } else { listOf( ValidationResult.error("This email cannot be used for this profile.", listOf("email")) ) } } ``` * TypeScript (preview) ```typescript import { CommandValidator, injectable, validator } from '@cratis/arc.core'; export abstract class ProfileDirectory { abstract isEmailAllowed(email: EmailAddress, signal: AbortSignal): Promise; } @validator(UpdateProfile) @injectable(ProfileDirectory) export class UpdateProfileValidator extends CommandValidator { constructor(profiles: ProfileDirectory) { super(); this.ruleFor(command => command.name).notEmpty().minLength(3).maxLength(100); this.ruleFor(command => command.email) .mustAsync((_email, command, signal) => profiles.isEmailAllowed(command.email, signal)) .withMessage('This email cannot be used for this profile.'); } } ``` In C#, keep the `EmailAddressValidator` from the linked example: it owns the required/format rules, and this replacement validator takes over the command-level ones. Arc’s concept-aware `RuleFor` unwraps the property, so the three-argument `MustAsync` overload reads the named `command.Email` from the command when calling the application service. `MinimumLength` and `MaximumLength` are FluentValidation method names. The shapes genuinely diverge here. Arc.Kotlin’s shared fluent surface is deliberately literal and has **no** asynchronous or predicate rule, so the JVM sibling is an ordinary `CommandValidator` whose `validate` is already a suspending function, added beside the `FluentModelValidator` that keeps projecting the literal rules to the client. There is also no `CancellationToken` parameter on the JVM: a suspending validator runs inside Arc’s command coroutine, so cancelling the request cancels what it awaits. Either way the asynchronous directory rule remains server-side, while the name rules and supported format rules are generated for the client. No `minLength`, `maxLength`, or `pattern` props are supported on the built-in `InputTextField`. A server preflight runs pipeline filters and validators but skips the command handler and handler-argument resolution. Validators and filters must avoid unwanted side effects and disclosing sensitive information. A successful check does not reserve an email or enforce uniqueness under concurrent writes: enforce that invariant when the application performs the write as well. ## Read the response Arc returns a command result with a `validationResults` array, not a `validationErrors` dictionary. This is an **illustrative failure envelope**; the correlation ID and rule message are example values: ```json { "correlationId": "34c66385-006a-4ab6-a3f9-cb34c751ec30", "isSuccess": false, "isValid": false, "isAuthorized": true, "hasExceptions": false, "validationResults": [ { "severity": 3, "message": "This email cannot be used for this profile.", "members": ["email"], "state": null, "reason": "rule" } ], "exceptionMessages": [], "exceptionStackTrace": "", "authorizationFailureReason": "", "response": null } ``` A failed preflight has no command response to consume; some server results omit that optional member rather than returning null. CommandForm matches `members` to fields and displays the first matching message by default. Use an [error summary](/arc/frontend/react/command-form/validation/#accessing-validation-state) for all messages or results without members. ## Limits and operating guidance * `isExecuting` reports **execution**, not preflight network activity. CommandForm currently exposes no validation-pending/loading state. Do not label it “Checking availability.” * A previous validity verdict can remain while a new request runs. Internal issue tokens reject results overtaken by later applied validation, but do not guarantee every visible verdict describes the latest edit. For a custom gated UI, use the [revision-guarded pattern](/arc/frontend/react/command-form/validation/#progressive-validation). * Preflight may short-circuit on client rules; it does not always reach server authorization. Execution must validate and authorize again. * The timer reduces traffic; it does not stop abuse. Configure ASP.NET Core rate-limiting middleware/policies or equivalent gateway controls in your application and verify they cover the generated validation endpoint. Arc does not provide the previously illustrated `RateLimitAttribute(MaxRequests, TimeWindowSeconds)` API. * Keep lookups indexed and bounded. Cache only with correct tenant/user keys and expiration; stale cached availability is advisory, never write-time enforcement. * Prefer submit-only validation for expensive endpoints or rules whose answers reveal private data. Handle request failures without treating them as success. ## See also * [Validation](/arc/frontend/react/command-form/validation/) * [Backend command validation](/arc/backend/csharp/commands/command-validation/) * [Form lifecycle](/arc/frontend/react/command-form/form-lifecycle/) * [Customization](/arc/frontend/react/command-form/customization/) # Creating custom fields > Adapt a native or library control to CommandForm while preserving change, blur, and validation behavior. Use a custom field when the built-in props do not cover your control—for example, a text input with an explicit accessible label and `maxLength`. `asCommandFormField` connects the control to the surrounding form. It does not turn HTML attributes into command rules. []() ## Basic anatomy This is a complete `fields/MyField.tsx` module. It converts native change events to strings and forwards the form’s **blur callback**, so the default blur-validation behavior still runs. ```tsx import { useId, type ChangeEvent } from 'react'; import { asCommandFormField, type WrappedFieldProps } from '@cratis/arc.react/commands'; interface MyFieldProps extends WrappedFieldProps { label: string; placeholder?: string; maxLength?: number; } export const MyField = asCommandFormField( function TextInput(props: MyFieldProps) { const generatedId = useId(); const id = props.id ?? generatedId; return (
); }, { defaultValue: '', extractValue: (event: unknown) => (event as ChangeEvent).target.value } ); ``` The extractor above expects a native input change event because that is the only event this control emits. If your control also emits values directly, handle both shapes explicitly. The component lets CommandForm render messages, rather than rendering the same errors a second time. Use a **generated command class** as the constructor. This complete form uses `UpdateProfile` from the [overview](/arc/frontend/react/command-form/), not an interface named like a command: ```tsx import { CommandForm } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; import { MyField } from './fields/MyField'; export function ProfileForm() { return ( value={c => c.name} label="Name" maxLength={100} /> value={c => c.email} label="Email" /> ); } ``` No `title` is supplied because each control renders its own label. If you use a form-rendered `title` instead, forward `props.id` to the control; do not render a second label with the same text. `maxLength` constrains normal typing but does not validate programmatically supplied values. CommandForm uses `noValidate`; keep length/format/required rules on the command. ## WrappedFieldProps | Prop | Type | Contract | | --- | --- | --- | | `value` | `TValue` | Current display value; configured fallback is used only for `undefined`, not `null`. | | `id` | `string \| undefined` | Forward to the rendered input for a single-input field, or to the group container for a grouped field. A caller-supplied id takes precedence over the generated one. | | `onChange` | `(valueOrEvent: TValue \| unknown) => void` | Forward the control’s change event/value for extraction and binding. | | `onBlur` | `(() => void) \| undefined` | Forward to the control’s blur event to preserve form blur validation. | | `invalid` | `boolean` | Whether this field has a displayed error. | | `required` | `boolean` | Explicit control override or inferred descriptor flag, not a new command rule. | | `errors` | `string[]` | Messages made available to the adapter; rendering them yourself is optional. | ## Configuration object The configuration requires `defaultValue: TValue` and optionally accepts `extractValue: (event: unknown) => TValue`. Without an extractor the emitted value is used directly. A fallback only changes display; it does not seed the command or its baseline. Define a deliberate null/empty policy for numeric/date controls rather than silently converting every empty value to a business value. For a field with multiple inputs, set `groupRole: 'group'` (or `'radiogroup'` for radio options) in the `asCommandFormField` configuration alongside `defaultValue` and any `extractValue`. CommandForm then renders the `title` as the group’s accessible name, not as a label for the first option. Forward `props.id` to the group container if it needs an id; never put it on one of the options. Label each option individually. The form-rendered title is omitted when `showTitles={false}`; in that case provide an accessible group name yourself. Existing single-input adapters need no configuration change. ## How a field is recognized `asCommandFormField` marks the returned component with a static `isCommandFormField` property. Its `displayName` is `CommandFormField` as a compatibility fallback. It also binds itself when an opaque layout creates or transforms the field at render time, so custom layouts can preserve the original element type and change its presentation/binding props before registration. Binding uses a private framework marker, not the presence of an `onValueChange` prop. A layout can wrap `onValueChange` without disabling binding; the form updates the command before invoking that consumer callback. Do not treat that callback’s presence as proof of prior binding. Keep command-form field markers intact Do not strip field static markers. If both recognition markers disappear, CommandForm no longer discovers the field. In development, an unresolved accessor produces a warning; production remains non-throwing and unbound. Use a simple accessor such as `c => c.name` rather than a computed expression. For a hand-written adapter, prefer `withCommandFormFieldBinding` over marker-only registration. This complete alternative module includes the injected blur contract: ```tsx import { withCommandFormFieldBinding, type CommandFormFieldProps, type InjectedCommandFormFieldProps } from '@cratis/arc.react/commands'; type HandRolledProps = CommandFormFieldProps & InjectedCommandFormFieldProps; export const HandRolledField = withCommandFormFieldBinding((props: HandRolledProps) => ( props.onValueChange?.(event.target.value)} onBlur={props.onBlur} required={props.required} /> )); ``` Supply a `title` when using that adapter to provide its accessible name. `markAsCommandFormField` remains supported for compatibility through visible child trees; `withCommandFormFieldBinding` is the choice for fields crossing custom component boundaries. Neither replaces error rendering by the surrounding form. ## Example: PrimeReact InputText This **optional integration module**, `fields/PrimeInputTextField.tsx`, needs a compatible installed PrimeReact package and its theme setup. It does not require Chronicle or make PrimeReact an Arc dependency. ```tsx import { useId, type ChangeEvent } from 'react'; import { InputText, type InputTextProps } from 'primereact/inputtext'; import { asCommandFormField, type WrappedFieldProps } from '@cratis/arc.react/commands'; interface PrimeInputTextFieldProps extends WrappedFieldProps { label: string; placeholder?: InputTextProps['placeholder']; maxLength?: InputTextProps['maxLength']; type?: InputTextProps['type']; } export const PrimeInputTextField = asCommandFormField( function PrimeTextInput(props: PrimeInputTextFieldProps) { const generatedId = useId(); const { label, value, onChange, invalid, required, errors, id: providedId, ...rest } = props; const id = providedId ?? generatedId; void errors; return (
); }, { defaultValue: '', extractValue: (event: unknown) => (event as ChangeEvent).target.value } ); ``` `onBlur` deliberately remains in `...rest`, which is spread onto `InputText`; **blur is forwarded**. If you destructure it later, add `onBlur={onBlur}` explicitly. Do not remove that forwarding. The adapter excludes `errors` from the input and leaves messages to the form to avoid duplicate error text. ### Usage This complete usage component supplies the real command constructor and all adapter-required props: ```tsx import { CommandForm } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; import { PrimeInputTextField } from './fields/PrimeInputTextField'; export function ProfileForm() { return ( value={c => c.name} label="Name" maxLength={100} /> value={c => c.email} label="Email" type="email" /> ); } ``` ## Advanced examples Apply the same adapter contract to rich text, numeric inputs, and switches: * **Text area:** forward `onChange` and `onBlur`; extract `ChangeEvent.target.value`. Add `rows`/`maxLength` only if your adapter forwards them. * **PrimeReact InputNumber:** use its `onValueChange` event and extract its `value`, deciding explicitly what `null` means. Forward `onBlur` and any required/invalid attributes supported by your installed version. * **Switch:** bind `checked`, extract the library’s boolean value, and forward `onBlur`. A required flag does not require the value to be true; consent needs a command rule. These are adapter design notes, not drop-in components. Check the installed control’s prop/event definitions before implementing an adapter; a wrapper cannot safely assume all libraries emit native events. ## Type safety Supply the command type in JSX (` ... />`) or annotate the accessor parameter. This checks property names. Most generic field accessors return `unknown`, so a valid property name alone does not prove its value type matches the control; do not bind a `Date` or number to a string adapter without an explicit conversion policy. ## Best practices Forward blur as well as change. Choose one owner for error display; custom fields that render `errors` themselves must opt out of the form’s display to avoid duplicates, and `showErrors={false}` does not suppress an adapter’s own markup. Associate labels and messages with controls in your adapter, test keyboard navigation, and keep blocking rules on the command. ## Reusable field library Export the adapters you actually implement from your application’s field module. Keep generated commands in the configured proxy output, and import those classes when constructing forms. Do not substitute application interfaces for executable command constructors. ## See also * [Field types](/arc/frontend/react/command-form/field-types/) * [Customization](/arc/frontend/react/command-form/customization/) * [Validation](/arc/frontend/react/command-form/validation/) * [CommandForm overview](/arc/frontend/react/command-form/) # Customization > Customize CommandForm titles, messages, field containers, decorators, and tooltips using their actual prop contracts. Change presentation without creating another command instance. CommandForm exposes rendering components for individual fields and context for form-level summaries. The examples below use the generated `UpdateProfile` from the [overview](/arc/frontend/react/command-form/). ## Custom titles `showTitles={false}` hides the default titles. Supply accessible labels in your own controls rather than substituting headings and assuming they label an input. For single-input fields, the built-in title renderer associates its label with the control using a generated id, or a caller-provided `id` when supplied. Custom single-input fields must forward that id to their input. For radio groups and custom grouped fields, the title names the group without activating an option; [custom fields](/arc/frontend/react/command-form/custom-fields/#configuration-object) explains how to declare a group. ## Custom error rendering Use `showErrors={false}` to hide automatic field messages and form-level exception feedback, including any custom `exceptionDisplayComponent`, without invoking that replacement. Validation, result state, and execution callbacks still work. This complete component adds a descendant summary that reads the same form’s displayed validation results, including non-field failures: ```tsx import { CommandForm, InputTextField, useCommandFormContext } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; function ErrorSummary() { const { commandResult } = useCommandFormContext(); const results = commandResult?.validationResults ?? []; if (results.length === 0) return null; return (

Please review the profile

    {results.map((result, index) => (
  • {result.reason === 'rule' ? result.message : 'The request could not be validated. Please try again.'}
  • ))}
); } export function ProfileForm() { return ( value={c => c.name} title="Name" /> value={c => c.email} title="Email" /> ); } ``` There is no `instance.errors` dictionary. Read `commandResult.validationResults` or use `getFieldError('email')` for the first field message. Hooks must be **below** the form provider. `showErrors` does not clear validation, change `isValid`, or suppress messages that a custom field or summary chooses to render itself. This summary handles validation results only; if you hide automatic feedback, provide your own safe exception feedback as well. Keep rule messages suitable for end users and raw diagnostics in trusted handling. ## Custom field container A container receives exactly these props: | Prop | Type | Meaning | | --- | --- | --- | | `title` | `string \| undefined` | Field title, even when `showTitles` is false. | | `errorMessage` | `string \| undefined` | First field message; undefined when `showErrors` is false. | | `children` | `React.ReactNode` | The field content, including the default title if `showTitles` is true. | This complete renderer keeps the title supplied in `children` and adds a border/message: ```tsx import { type FieldContainerProps } from '@cratis/arc.react/commands'; export function BorderedField({ errorMessage, children }: FieldContainerProps) { return (
{children} {errorMessage &&

{errorMessage}

}
); } ``` Set `fieldContainerComponent={BorderedField}` on your form. If your container renders `title` itself, also set `showTitles={false}` to avoid duplicate titles. Extra props such as `helpText` are **not** automatically passed simply because you add them to a custom interface. A custom container owns field error rendering: the form does not also render `errorDisplayComponent` outside it. Choose either a container-owned message or the normal error display, rather than expecting both to compose. ## Custom field decorator A decorator runs for fields with an `icon` or `description`. This complete renderer uses native CSS and a browser tooltip: ```tsx import { type FieldDecoratorProps } from '@cratis/arc.react/commands'; export function InlineDecorator({ icon, description, children }: FieldDecoratorProps) { return (
{icon && } {children}
); } ``` | Prop | Type | | --- | --- | | `icon` | `React.ReactElement \| undefined` | | `description` | `string \| undefined` | | `children` | `React.ReactNode` | Set `fieldDecoratorComponent={InlineDecorator}` on the form. A custom decorator replaces default icon/description decoration, including the default `tooltipComponent` path. Render its description or tooltip yourself. Do not put essential instructions only in a hover tooltip. ## Custom error display Without a custom container, `errorDisplayComponent` replaces the default field message element. This complete renderer uses the actual interface: ```tsx import { type ErrorDisplayProps } from '@cratis/arc.react/commands'; export function FieldErrors({ errors, fieldName }: ErrorDisplayProps) { return (
{errors.map((error, index) =>

{error}

)}
); } ``` `errors` is `string[]`; `fieldName` is optional. The current form passes its selected first field message as a one-element array, not all backend messages for that field. Use a context summary for the full result. ## Safe exception feedback Unexpected failures should tell users what to do next, not expose server diagnostics. By default, CommandForm shows an accessible alert containing only: > An unexpected error occurred. Please try again. Set `exceptionMessage` to safe application-specific or localized text. An explicitly empty string stays empty rather than falling back to the default. This **configuration fragment** assumes the `CommandForm` and generated `UpdateProfile` imports from the overview: ```tsx ``` To replace the entire default panel, provide `exceptionDisplayComponent`. It receives only `{ message: string }` through the exported `ExceptionDisplayProps` interface, never the command result, diagnostic messages, or stack trace. This complete component supplies accessible feedback in its replacement: ```tsx import { CommandForm, InputTextField, type ExceptionDisplayProps } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; function ExceptionNotice({ message }: ExceptionDisplayProps) { return
{message}
; } export function ProfileForm() { return ( value={c => c.name} title="Name" /> value={c => c.email} title="Email" /> ); } ``` `errorDisplayComponent` remains exclusively for field validation errors; CommandForm never calls it for a form-level exception. `showErrors={false}` hides both the default exception panel and the custom replacement without invoking the replacement. Treat `exceptionMessage` as user-facing content: do not populate it from raw exception diagnostics. Original diagnostics remain available through [execution callbacks and result state](/arc/frontend/react/command-form/form-lifecycle/#exception-diagnostics-and-display) for trusted handling, not automatic display. ## Custom tooltip component `tooltipComponent` receives `{ description: string; children: React.ReactNode }` and wraps the default decorated field. The following **optional PrimeReact renderer** requires a compatible installed PrimeReact package and its theme setup; it is not an Arc dependency: ```tsx import { useId } from 'react'; import { Tooltip } from 'primereact/tooltip'; import { type TooltipWrapperProps } from '@cratis/arc.react/commands'; export function PrimeTooltip({ description, children }: TooltipWrapperProps) { const id = `field-tooltip-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`; return ( <>
{children}
); } ``` The ID remains stable across renders. Use this via `tooltipComponent={PrimeTooltip}` when no custom decorator replaces that path. ## Custom CSS classes `errorClassName` defaults to `p-error`; `iconAddonClassName` defaults to `p-inputgroup-addon`. Class names alone do not install CSS. This **configuration fragment** assumes `CommandForm`, `InputTextField`, and `UpdateProfile` imports and application CSS for the two classes: ```tsx value={c => c.email} title="Email" icon={@} /> ``` The default error element also has inline styles, including color, so a class declaration may not override every property. Use `errorDisplayComponent` for complete styling control. ## Framework integration examples PrimeReact and Tailwind are optional presentation choices, not CommandForm requirements. For PrimeReact inputs, use the [custom InputText adapter](/arc/frontend/react/command-form/custom-fields/#example-primereact-inputtext) and install the CSS required by your chosen PrimeReact version. PrimeFlex utility classes require PrimeFlex (or equivalent rules), separately from a PrimeReact theme. For a Tailwind application, supply typed `FieldDecoratorProps`/`ErrorDisplayProps` components containing your utility classes and ensure the application scans those files. Do not assume Tailwind recognizes PrimeFlex class names such as `flex-column` or `md:flex-row` with identical semantics. ## Combining customizations Choose the ownership boundary deliberately: 1. Keep default titles or render them yourself, not both. 2. Let either the form’s error renderer or a custom container own field messages. 3. Let a custom decorator own icon/description/tooltip composition, or use the default decorator plus `tooltipComponent`. 4. Keep blocking rules in command validation. Changing presentation does not change execution semantics. ## See also * [Creating custom fields](/arc/frontend/react/command-form/custom-fields/) * [Field types](/arc/frontend/react/command-form/field-types/) * [Validation](/arc/frontend/react/command-form/validation/) * [Advanced patterns](/arc/frontend/react/command-form/advanced-patterns/) # Async data loading > Load field options separately from command values, and populate a form's baseline from a query. Options and edited values have different owners: React can hold a product list while CommandForm owns the selected product ID. Do not create a second command to load options for the form. ## Loading options for SelectField This complete component expects a generated `CreateOrder` with `productId: string` and `quantity: number`. `loadProducts` is an application-owned function that returns option objects; it is not an Arc API. ```tsx import { useEffect, useState } from 'react'; import { CommandForm, NumberField, SelectField } from '@cratis/arc.react/commands'; import { CreateOrder } from './commands/CreateOrder'; type Product = { id: string; name: string }; type OrderFormProps = { loadProducts: () => Promise }; export function OrderForm({ loadProducts }: OrderFormProps) { const [products, setProducts] = useState(); const [error, setError] = useState(''); useEffect(() => { let active = true; setProducts(undefined); setError(''); void loadProducts().then( values => { if (active) setProducts(values); }, () => { if (active) setError('Products could not be loaded.'); } ); return () => { active = false; }; }, [loadProducts]); if (error) return

{error}

; if (!products) return

Loading products…

; return ( value={c => c.productId} title="Product" options={products} optionIdField="id" optionLabelField="name" placeholder="Select a product" /> value={c => c.quantity} title="Quantity" min={1} /> ); } ``` Supply a stable `loadProducts` function (module-level or memoized) so parent renders do not restart the effect. The active flag ignores superseded/unmounted results; it does not abort an already sent request. Implement HTTP status checks and response decoding in your loader. The command must validate the selected product and quantity; `min={1}` is only an input attribute. ## Dependent dropdowns Read the country from a **descendant** using the zero-argument `useCommandInstance()`. This complete component expects a generated `SaveLocation` with string `country` and `city` properties. The caller supplies countries and a stable application loader. ```tsx import { useEffect, useState } from 'react'; import { CommandForm, SelectField, useCommandInstance } from '@cratis/arc.react/commands'; import { SaveLocation } from './commands/SaveLocation'; type Option = { id: string; name: string }; type LoadCities = (country: string) => Promise; type CityResult = { country: string; options: Option[]; error: string }; function CityField({ loadCities }: { loadCities: LoadCities }) { const command = useCommandInstance(); const country = command.country; const [result, setResult] = useState(); useEffect(() => { let active = true; if (country) { void loadCities(country).then( options => { if (active) setResult({ country, options, error: '' }); }, () => { if (active) setResult({ country, options: [], error: 'Cities could not be loaded.' }); } ); } return () => { active = false; }; }, [country, loadCities]); const current = country && result?.country === country ? result : undefined; return ( <> value={c => c.city} title="City" options={current?.options ?? []} optionIdField="id" optionLabelField="name" placeholder={!country ? 'Select a country first' : current ? 'Select a city' : 'Loading cities…'} /> {current?.error &&

{current.error}

} ); } export function LocationForm({ countries, loadCities }: { countries: Option[]; loadCities: LoadCities }) { return ( { if (field === 'country' && oldValue !== newValue) command.city = ''; }} > value={c => c.country} title="Country" options={countries} optionIdField="id" optionLabelField="name" placeholder="Select a country" /> ); } ``` Changing country clears the old city synchronously during the field edit, before that edit’s silent validation. Matching the response to its country prevents stale options from appearing, and cleanup ignores superseded requests. Validate country/city membership on the server; an empty or failed option list is not an authorization mechanism. ## Populating a form from a query Use `populateFromQuery` when the form should establish field baselines from a single-instance read. This complete component expects generated `GetUserProfile` and `UpdateProfile` classes; the query accepts `userId` and returns a single object with matching `name`/`email` properties. If your update command also needs an ID, seed that required property explicitly—it is not inferred from query arguments. ```tsx import { CommandForm, InputTextField } from '@cratis/arc.react/commands'; import { GetUserProfile } from './queries/GetUserProfile'; import { UpdateProfile } from './commands/UpdateProfile'; export function ProfileEditor({ userId }: { userId: string }) { return ( value={c => c.name} title="Name" /> value={c => c.email} title="Email" /> ); } ``` The query must be **single-instance**, not enumerable; an enumerable query throws. For an emitting source, use `populateFromObservableQuery` instead, with the same `populateFromQueryArgs` prop. Choose one source. Observable updates require an actual emitting backend source; Arc does not infer one from a database write. Matching fields are seeded by name through `setInitialValues`, including falsy values. These populated properties become the change-tracking baseline. Unrelated edits are preserved when other populated properties change; a changed source value for an edited property can overwrite that edit. Decide whether live repopulation is appropriate for your editor. The `key` above deliberately creates a fresh form when the selected user changes. Population is not a loading/error UI. If editing or saving before a successful load is unsafe, load and inspect the query result in the parent and mount the form only when ready. Do not mistake `isValid` for query-loading state. ## Initial and current values | Source | Behavior | | --- | --- | | `initialValues` | Synchronous seed/baseline. Undefined entries supply nothing. Changing this prop alone after mount does not repopulate the form. | | Field `currentValue` | Seed/baseline for that field; undefined supplies nothing. | | Population query | Maps registered fields and updates their baselines when resolved values change. | | `currentValues` | Reactive overlay. A present key is written even if null/undefined (when allowed by the command type); an absent key leaves the property alone. Later overlay edits do not reset the baseline. | Where layers overlap, the merge order from lowest to highest priority is `currentValues`, query population, field `currentValue`, then defined `initialValues`. Avoid competing sources for the same property. For a late load that should be considered unchanged, use query population or mount a new form with loaded `initialValues`, rather than assuming every `currentValues` update resets change tracking. ## Per-field control These population props do not restrict explicit `initialValues`/`currentValues` supplied by the caller: | Prop | Effect | | --- | --- | | `noInitialValue` | Skips this field during query population. | | `initialValue` | Computes the field’s value from the source; defaults to matching the command property’s name. | | `populationKey` | Identifies captured semantics (for example locale) that should trigger recomputation from the current source. | This **field fragment** belongs inside a form whose population query returns `UserProfile`, and whose generated `UpdateProfile` includes `displayName`. It needs the corresponding application type imports and a `locale` variable: ```tsx value={c => c.displayName} initialValue={(profile: UserProfile) => locale === 'nb' ? `${profile.lastName}, ${profile.firstName}` : `${profile.firstName} ${profile.lastName}`} populationKey={locale} title="Display name" /> ``` Each mounted field has a stable registration. Recreating an equivalent inline callback does not repopulate it; only committed callbacks become visible. A source or `populationKey` change evaluates the latest committed callback, including when both change in the same commit. Use a stable semantic key, not a fresh object every render. Do not put side effects in `initialValue`. ## See also * [CommandForm overview](/arc/frontend/react/command-form/) * [Working with hooks](/arc/frontend/react/command-form/hooks/) * [Field types](/arc/frontend/react/command-form/field-types/) * [Form lifecycle](/arc/frontend/react/command-form/form-lifecycle/) # Field types > Choose a built-in CommandForm control and distinguish binding props from command validation rules. Built-in fields synchronize values with the surrounding form and display its validation messages. Import them from `@cratis/arc.react/commands` and render them inside `CommandForm`. ## Type safety Use an explicit command type for fields created by `asCommandFormField`. This **field fragment** assumes imports for `InputTextField` and your generated `UpdateProfile` class with a string `name` property: ```tsx value={c => c.name} title="Name" /> ``` Omitting both the generic and an accessor parameter annotation leaves `c` as `unknown`. The generic checks property names; most field accessors return `unknown`, so it does not guarantee the selected property’s value type matches the control. Use simple property accessors, not computed or nested expressions. Radio fields infer the selected value type from the accessor itself. This **field fragment** expects a generated `UserSettingsCommand` whose `role` accepts `'reader'` or `'admin'`: ```tsx c.role} title="Role" options={[ { value: 'reader', label: 'Reader' }, { value: 'admin', label: 'Administrator' } ]} /> ``` ## Common props | Prop | Type | Contract | | --- | --- | --- | | `value` | Property accessor | Required; selects the command property. | | `title` | `string` | Field title shown by the default title renderer. | | `required` | `boolean` | Overrides the control’s inferred required flag, not the command’s validation rules. | | `icon` | `React.ReactElement` | Optional icon decoration. | | `description` | `string` | Optional description/tooltip decoration. | | `className`, `style` | `string`, `React.CSSProperties` | Control or wrapper styling, depending on field type. | | `currentValue` | `unknown` (typed for radios) | Explicit field seed; undefined supplies nothing. Binding supplies the live value internally. | | `initialValue`, `noInitialValue`, `populationKey` | Population metadata | See [Data loading](/arc/frontend/react/command-form/data-loading/#per-field-control). | Fields default to required when their generated `PropertyDescriptor.isOptional` is false (and also when no descriptor is resolved). This sets an input attribute. CommandForm has `noValidate`, so it does **not** enforce browser required/type/range constraints on submit. Payload presence validation checks null/undefined, not empty strings or false booleans. Use explicit [command validation rules](/arc/frontend/react/command-form/validation/#required-fields). `onValueChange`, `onBlur`, descriptor, and field-name props participate in internal binding. Custom adapters must forward the injected blur handler. Do not attach a public `onBlur` and assume it overrides or composes with the form’s injected validation callback; use the form’s interaction callbacks or an adapter that preserves forwarding. ## Field state * Edits update the command through the form setter and advance its revision. * Silent validation updates form validity; `validateOn` controls when displayed errors are refreshed. * Most controls add an invalid border; `RangeField` currently does not vary its border with invalid state, though the form still displays its message. * Display defaults do not seed payload values. Define intentional initial values on the form. ## Available fields | Field | Value and control | | --- | --- | | [InputTextField](/arc/frontend/react/command-form/field-types/input-text-field/) | Strings; text, email, password, date/time text, color, URL, telephone, and search. | | [NumberField](/arc/frontend/react/command-form/field-types/number-field/) | Numbers; empty/invalid native text input converts to zero. | | [TextAreaField](/arc/frontend/react/command-form/field-types/text-area-field/) | Multi-line strings. | | [CheckboxField](/arc/frontend/react/command-form/field-types/checkbox-field/) | Boolean checked state. | | [RadioButtonField](/arc/frontend/react/command-form/field-types/radio-button-field/) | One typed option for a property. | | [RadioGroupField](/arc/frontend/react/command-form/field-types/radio-group-field/) | A list of typed options for a property. | | [RangeField](/arc/frontend/react/command-form/field-types/range-field/) | Numeric slider with value display. | | [SelectField](/arc/frontend/react/command-form/field-types/select-field/) | String values from a native select; option IDs are stringified. | ## Styling Most fields use full-width inline layout, `0.75rem` padding, and `0.375rem` rounded borders. Theme colors use CSS variables with some fallbacks; application CSS must define any variables or utility classes you rely on. There is no automatic UI-library installation or comprehensive accessible label/error association. For explicit control IDs, ARIA props, or unsupported native attributes, use [custom fields](/arc/frontend/react/command-form/custom-fields/). ## See also * [CommandForm overview](/arc/frontend/react/command-form/) * [Validation](/arc/frontend/react/command-form/validation/) * [Creating custom fields](/arc/frontend/react/command-form/custom-fields/) # CheckboxField > Bind a boolean command property to a checkbox. A checkbox input for boolean values. An undefined value displays as unchecked; clicking writes the control’s boolean checked state. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `(instance: TCommand) => unknown` | | **Required.** Accessor function returning the property value from the command instance. | | `title` | `string` | | The label for the field (shown when `showTitles` is enabled). | | `label` | `string` | | Label text displayed next to the checkbox. | | `required` | `boolean` | | Override automatic required detection. | A `required` flag does not require this command property to be true: `false` is a present boolean and CommandForm uses `noValidate`. A consent checkbox needs an explicit backend rule requiring true. The built-in adjacent label is not linked with an input ID; use a [custom field](/arc/frontend/react/command-form/custom-fields/) for explicit accessible label association. See [Common props](/arc/frontend/react/command-form/field-types/#common-props) for styling and population. ## Example These illustrative field fragments require imports for `CheckboxField` from `@cratis/arc.react/commands` and your generated `UserCommand` with boolean `agreeToTerms` and `newsletter` properties. Place them inside that command’s form, initialized with intentional boolean defaults. ```tsx value={c => c.agreeToTerms} title="Terms of Service" label="I agree to the terms and conditions" /> value={c => c.newsletter} title="Newsletter" label="Send me newsletter updates" /> ``` ## See Also * [Field Types Overview](/arc/frontend/react/command-form/field-types/) * [CommandForm Overview](/arc/frontend/react/command-form/) # InputTextField > Bind a string command property to a native text-based input. A text input supporting the native input types listed below. Values emitted by this field are strings, including date/time and color inputs—not JavaScript `Date` objects. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `(instance: TCommand) => unknown` | | **Required.** Accessor function returning the property value from the command instance. | | `title` | `string` | | The label for the field. | | `type` | `'text' \| 'email' \| 'password' \| 'color' \| 'date' \| 'datetime-local' \| 'time' \| 'url' \| 'tel' \| 'search'` | `'text'` | The HTML input type. | | `placeholder` | `string` | | Placeholder text shown when empty. | | `required` | `boolean` | | Override automatic required detection. | The shared `className`, `style`, decoration, and population props are described in [Common props](/arc/frontend/react/command-form/field-types/#common-props). This built-in field does not accept `minLength`, `maxLength`, `pattern`, arbitrary input IDs, or arbitrary native attributes; use a [custom adapter](/arc/frontend/react/command-form/custom-fields/) when needed. `type="email"` does not enforce an email rule because CommandForm uses `noValidate`; see [Validation](/arc/frontend/react/command-form/validation/#required-fields). ## Examples These are illustrative field fragments, not complete forms. Import `InputTextField` from `@cratis/arc.react/commands` and your generated `UserCommand` class, then place each fragment inside `CommandForm command={UserCommand}`. The selected properties must be strings. See the [complete form checkpoint](/arc/frontend/react/command-form/#start-with-a-generated-command). **Text Input:** ```tsx value={c => c.name} title="Full Name" placeholder="Enter your name" /> ``` **Email Input:** ```tsx value={c => c.email} type="email" title="Email" placeholder="your@email.com" /> ``` **Password Input:** ```tsx value={c => c.password} type="password" title="Password" /> ``` **Date Input:** ```tsx value={c => c.birthDate} type="date" title="Birth Date" /> ``` **Color Picker:** ```tsx value={c => c.favoriteColor} type="color" title="Favorite Color" /> ``` **URL Input:** ```tsx value={c => c.website} type="url" title="Website" placeholder="https://example.com" /> ``` ## See Also * [Field Types Overview](/arc/frontend/react/command-form/field-types/) * [CommandForm Overview](/arc/frontend/react/command-form/) # NumberField > Bind a numeric command property to a native number input. A numeric input with native min/max attributes and step increments. These attributes guide input; command range rules enforce validity because the form uses `noValidate`. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `(instance: TCommand) => unknown` | | **Required.** Accessor function returning the property value from the command instance. | | `title` | `string` | | The label for the field. | | `placeholder` | `string` | | Placeholder text shown when empty. | | `min` | `number` | | Native input’s minimum attribute; not a command rule. | | `max` | `number` | | Native input’s maximum attribute; not a command rule. | | `step` | `number` | | Increment/decrement step size. | | `required` | `boolean` | | Override automatic required detection. | The field displays `0` for an undefined value; editing the native control to empty or unparsable text also writes `0`. It cannot preserve a distinct empty numeric value. Use a custom adapter if your command needs nullable/empty semantics. See [Common props](/arc/frontend/react/command-form/field-types/#common-props) for styling and population. ## Example These illustrative field fragments require imports for `NumberField` from `@cratis/arc.react/commands` and the named generated command classes. Place each fragment in its matching form; `age` and `price` are numeric properties. They are alternatives, not one complete component. ```tsx value={c => c.age} title="Age" min={0} max={120} step={1} /> value={c => c.price} title="Price" min={0} step={0.01} placeholder="0.00" /> ``` ## See Also * [Field Types Overview](/arc/frontend/react/command-form/field-types/) * [CommandForm Overview](/arc/frontend/react/command-form/) # RadioButtonField > Assign a typed option value to a command property with one radio button. A single radio input for assigning a specific value to a command property. `RadioButtonField` infers the allowed `setValue` type from the bound accessor. That keeps each radio option aligned with the command property it updates. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `(instance: TCommand) => TValue` | | **Required.** Accessor function returning the property value from the command instance. | | `setValue` | `TValue` | | **Required.** Value assigned when the radio button is selected. The type is inferred from `value`. | | `label` | `React.ReactNode` | | Content displayed next to the radio button. | | `title` | `string` | | The label for the field (shown when `showTitles` is enabled). | | `required` | `boolean` | | Override automatic required detection. | | `disabled` | `boolean` | `false` | Disables the radio button. | Selection assigns `setValue` directly, and checked state uses `Object.is`. Prefer primitive option values; freshly constructed objects do not compare structurally. Radios sharing a bound property use its field name for grouping. See [Common props](/arc/frontend/react/command-form/field-types/#common-props); `required` is a control flag, not a replacement for command validation. ## Example These illustrative field fragments require `RadioButtonField` from `@cratis/arc.react/commands` and a generated `NotificationPreferences` whose `contactMethod` accepts `'email'` and `'sms'`. Place both inside its CommandForm. Annotating the accessor parameter enables option type inference; do not pass the command type as this component’s accessor generic. ```tsx c.contactMethod} setValue="email" label="Email" title="Preferred Contact Method" /> c.contactMethod} setValue="sms" label="SMS" /> ``` ## See Also * [RadioGroupField](/arc/frontend/react/command-form/field-types/radio-group-field/) * [Field Types Overview](/arc/frontend/react/command-form/field-types/) * [CommandForm Overview](/arc/frontend/react/command-form/) # RadioGroupField > Select one typed command-property value from a group of radio options. A grouped radio input for selecting one value from a predefined set of options. `RadioGroupField` infers each option value type from the bound accessor. That keeps every option aligned with the command property it updates. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `(instance: TCommand) => TValue` | | **Required.** Accessor function returning the property value from the command instance. | | `options` | `RadioGroupFieldOption[]` | | **Required.** Options rendered by the group. Each `value` must match the accessor return type. | | `direction` | `'horizontal' \| 'vertical'` | `'vertical'` | Layout direction for the options. | | `title` | `string` | | The label for the field (shown when `showTitles` is enabled). | | `required` | `boolean` | | Override automatic required detection. | Each option has a required `value` and `label: React.ReactNode`, plus optional `disabled: boolean`. Checked state uses `Object.is`, so prefer primitive values rather than freshly constructed objects. See [Common props](/arc/frontend/react/command-form/field-types/#common-props); the required control flag does not replace command rules. ## Example This illustrative field fragment requires `RadioGroupField` from `@cratis/arc.react/commands` and a generated `UserSettingsCommand` whose `role` accepts the shown values. Place it inside that command’s form. Annotate the accessor parameter to infer the allowed option value type. ```tsx c.role} title="Role" direction="horizontal" options={[ { value: 'reader', label: 'Reader' }, { value: 'admin', label: 'Administrator' }, { value: 'owner', label: 'Owner' } ]} /> ``` ## See Also * [RadioButtonField](/arc/frontend/react/command-form/field-types/radio-button-field/) * [Field Types Overview](/arc/frontend/react/command-form/field-types/) * [CommandForm Overview](/arc/frontend/react/command-form/) # RangeField > Bind a numeric command property to a slider with a value display. A native slider with its current value displayed beside it. Input range attributes constrain the control, not programmatically supplied command values; enforce range rules on the command. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `(instance: TCommand) => unknown` | | **Required.** Accessor function returning the property value from the command instance. | | `title` | `string` | | The label for the field. | | `min` | `number` | `0` | Minimum value of the range. | | `max` | `number` | `100` | Maximum value of the range. | | `step` | `number` | `1` | Increment step size. | | `required` | `boolean` | | Override automatic required detection. | An undefined value uses a display fallback of zero. Seed a value within your chosen range rather than relying on the browser to reconcile an out-of-range command value. The form displays validation messages, but this control currently does not change its border for invalid state. See [Common props](/arc/frontend/react/command-form/field-types/#common-props) for styling and population. ## Example These illustrative field fragments require `RangeField` from `@cratis/arc.react/commands` and the named generated command classes. Place each inside its matching CommandForm, with numeric `volume` or `experience` properties. They are alternatives, not one complete component. ```tsx value={c => c.volume} title="Volume" min={0} max={100} step={1} /> value={c => c.experience} title="Years of Experience" min={0} max={50} step={1} /> ``` ## See Also * [Field Types Overview](/arc/frontend/react/command-form/field-types/) * [CommandForm Overview](/arc/frontend/react/command-form/) # SelectField > Bind a string command property to a native select with explicit option key and label fields. A native dropdown for choosing from option objects. Both option IDs and labels are stringified; selection emits a string, not the original object or numeric ID. ## Props | Prop | Type | Description | | --- | --- | --- | | `value` | `(instance: TCommand) => unknown` | **Required.** Accessor function returning the property value from the command instance. | | `title` | `string` | The label for the field. | | `options` | `Array<{ [key: string]: unknown }>` | **Required.** Array of option objects. | | `optionIdField` | `string` | **Required.** Name of the property to use as the option value. | | `optionLabelField` | `string` | **Required.** Name of the property to display as the option label. | | `placeholder` | `string` | Placeholder text shown for empty selection. | | `required` | `boolean` | Override automatic required detection. | All three option props are required: `options`, `optionIdField`, and `optionLabelField`. The optional placeholder has value `''`; command rules must reject it when a selection is mandatory. The native control can visually select an option while the command remains unset if you omit a placeholder/initial value, so seed intentionally. See [Common props](/arc/frontend/react/command-form/field-types/#common-props) and [Data loading](/arc/frontend/react/command-form/data-loading/) for asynchronous options. ## Example These illustrative configuration/field fragments require `SelectField` from `@cratis/arc.react/commands` and a generated `UserCommand` with string `country` and `role` properties. Place each selector inside that command’s form and keep its option array in component or module scope. ```tsx const countries = [ { id: 'us', name: 'United States' }, { id: 'uk', name: 'United Kingdom' }, { id: 'ca', name: 'Canada' }, { id: 'no', name: 'Norway' } ]; value={c => c.country} title="Country" options={countries} optionIdField="id" optionLabelField="name" placeholder="Select a country..." /> ``` With custom data: ```tsx const roles = [ { value: 'admin', display: 'Administrator' }, { value: 'user', display: 'Standard User' }, { value: 'guest', display: 'Guest' } ]; value={c => c.role} title="User Role" options={roles} optionIdField="value" optionLabelField="display" /> ``` ## See Also * [Field Types Overview](/arc/frontend/react/command-form/field-types/) * [CommandForm Overview](/arc/frontend/react/command-form/) # TextAreaField > Bind a string command property to a multi-line text area. A multi-line string input. An undefined value displays as an empty string without seeding the command. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `(instance: TCommand) => unknown` | | **Required.** Accessor function returning the property value from the command instance. | | `title` | `string` | | The label for the field. | | `placeholder` | `string` | | Placeholder text shown when empty. | | `rows` | `number` | `5` | Number of visible text rows. | | `cols` | `number` | | Number of visible text columns. | | `required` | `boolean` | | Override automatic required detection. | See [Common props](/arc/frontend/react/command-form/field-types/#common-props) for styling, decoration, and population. This field does not accept every native textarea attribute (for example `maxLength`); use a [custom adapter](/arc/frontend/react/command-form/custom-fields/) for additional attributes. Required and length rules belong in [command validation](/arc/frontend/react/command-form/validation/#required-fields). ## Example These illustrative field fragments require `TextAreaField` from `@cratis/arc.react/commands` and the named generated command classes. Place each inside its matching CommandForm. `bio` and `notes` are string properties; these are alternatives, not a complete component. ```tsx value={c => c.bio} title="Biography" placeholder="Tell us about yourself..." rows={8} /> value={c => c.notes} title="Additional Notes" rows={3} /> ``` ## See Also * [Field Types Overview](/arc/frontend/react/command-form/field-types/) * [CommandForm Overview](/arc/frontend/react/command-form/) # Form lifecycle > Transform a command synchronously, handle results, and execute the edited form from external controls. Use the form’s execution path when you want its result display, callbacks, and busy state to agree. Calling a separately created command can save different values from the ones on screen. ## Command result callbacks This complete component uses the generated `UpdateProfile` and its ordinary `{ name, email }` response from [Validation](/arc/frontend/react/command-form/validation/#backend-validation). The response type below is an application type, not a framework API. ```tsx import { useState } from 'react'; import { CommandForm, InputTextField } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; type ProfileDetails = { name: string; email: string }; export function ProfileForm() { const [message, setMessage] = useState(''); return ( <> command={UpdateProfile} initialValues={{ name: '', email: '' }} onSuccess={response => setMessage(`Accepted profile for ${response.name}`)} onFailed={() => setMessage('The profile was not accepted.')} onUnauthorized={() => setMessage('You are not authorized to update this profile.')} onException={() => setMessage('The server could not complete the request.')} onValidationFailure={results => setMessage(results.map(result => result.message).join(' '))} > value={c => c.name} title="Name" /> value={c => c.email} title="Email" type="email" />

{message}

); } ``` The form first calls `onSuccess(response)` or `onFailed(result)`. It then checks, in order, `hasExceptions`, `!isAuthorized`, and `!isValid`, invoking `onException(messages, stackTrace)`, `onUnauthorized()`, and `onValidationFailure(validationResults)` as applicable. Several callbacks can run for the same result; the last message setter wins in this example. Callbacks describe **returned command results**. An unexpectedly thrown/rejected promise, including a throwing transformation or callback, is not converted to `onException`. Catch rejections when calling the imperative handle, as below. Do not display raw stack traces to end users. Navigate only in `onSuccess`, using the response contract your handler actually returns. Nonempty custom field errors block submission, `onExecute()` and `formRef.execute()` before transformation or command execution. A blocked attempt publishes the same validation-failure result to `commandResult`, the caller and `onFailed`, then invokes `onValidationFailure`. It makes no HTTP request and does not enter the executing state. Errors set during `onBeforeExecute` are checked again before command execution. Editing custom errors updates the displayed custom failure. Clearing its last error restores the retained native result, or `undefined` if none exists; that can restore an earlier success, but does not execute or fire callbacks. A subsequent native/displayed result replaces the custom failure. Native field errors and silent validation remain separate, so clearing custom errors neither discards native errors nor leaves a custom failure in silent validity. Calling `commandInstance.execute()` directly bypasses this form-local gate. ## Exception diagnostics and display CommandForm separates user-facing exception feedback from diagnostics. The default panel displays only `An unexpected error occurred. Please try again.`. Use `exceptionMessage` for safe localized text or `exceptionDisplayComponent` to replace the panel; see [safe exception feedback](/arc/frontend/react/command-form/customization/#safe-exception-feedback). The display does not sanitize, clone, or mutate results: `onFailed` receives the original `ICommandResult`, `onException` receives the original diagnostic array and stack trace, and `commandResult` in the form context retains the original result. The form’s execution path also returns that same result. Keep these diagnostics in trusted logging or telemetry rather than rendering them to users. `showErrors={false}` hides automatic feedback, including the custom exception renderer without invoking it, but does not disable callbacks or change result state. The display checks `hasExceptions` **or** nonempty `exceptionMessages`. The `onException` callback remains flag-driven: messages without `hasExceptions: true` can produce safe feedback but do not invoke that callback. A descendant can supply a result through `useSetCommandResult`. The same safe display applies, but setting a result directly does not invoke execution callbacks. A subsequent successful result with no exception flag or messages removes the previous exception feedback, whether supplied by execution or a descendant. ## Before execute hook `onBeforeExecute` is a synchronous transformation: `(command: TCommand) => TCommand`. This alternative complete component trims the name before command validation and submission: ```tsx import { CommandForm, InputTextField } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; export function ProfileForm() { return ( { command.name = command.name.trim(); return command; }} > value={c => c.name} title="Name" /> value={c => c.email} title="Email" /> ); } ``` Return the executable instance. Do **not** return a boolean, a Promise, or `{ ...command }`: a spread object loses prototype methods such as `execute()` and does not preserve generated property accessors. There is no `beforeExecute` prop and no asynchronous cancellation contract. Confirm **before** invoking `formRef.current.execute()` instead. ## Reaching the form from a parent `formRef` supplies a stable `CommandFormHandle` with `execute(): Promise>` and live `isExecuting`, `isValid`, and `isAuthorized` getters. A ref read does not rerender a parent. Pair it with `onStateChange` when an external button needs reactive state. This complete component receives an application-owned asynchronous confirmation function. Both the footer button and native/keyboard form submission use the same gate; cancellation makes no execution call. ```tsx import { useRef, useState } from 'react'; import { CommandForm, InputTextField, type CommandFormHandle, type CommandFormState } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; type ProfilePanelProps = { confirmSave: () => Promise }; export function ProfilePanel({ confirmSave }: ProfilePanelProps) { const formRef = useRef(null); const busyRef = useRef(false); const [pending, setPending] = useState(false); const [message, setMessage] = useState(''); const [state, setState] = useState({ isExecuting: false, isValid: false, isAuthorized: true }); async function save() { if (busyRef.current || !formRef.current) return; busyRef.current = true; setPending(true); try { if (!await confirmSave()) return; const result = await formRef.current?.execute(); if (result) setMessage(result.isSuccess ? 'Profile accepted.' : 'Please review the result.'); } catch { setMessage('The save could not complete. Please try again.'); } finally { busyRef.current = false; setPending(false); } } return (
{ event.preventDefault(); event.stopPropagation(); void save(); }}>
Profile value={c => c.name} title="Name" /> value={c => c.email} title="Email" />

{message}

); } ``` Supply `confirmSave` from your dialog implementation. The hidden form-owned submit button enables implicit Enter submission from the text fields; the capture handler routes it through the same confirmation as the external Save button. The disabled fieldset keeps values stable while the question is open. The ref lock also stops same-tick double clicks. The example deliberately allows invalid values to be submitted so the command can display current validation errors; it does not treat a previous `isValid` result as permanent permission. Pass a stable ref from `useRef` or `useCallback`. An inline callback ref that sets parent state can repeatedly detach/attach and cause a render loop. `onStateChange` is different: its callback is read through a ref, runs on mount and on changes to its three state booleans, and safely accepts inline functions. `isValid` combines the latest applied silent validation with the absence of nonempty custom field errors; it is not a validation-pending state. `isAuthorized` is a local identity-role check. Neither is a server security boundary. `execute()` gates on custom errors, not these flags; once that gate passes, execution performs command validation/authorization. See [Validation](/arc/frontend/react/command-form/validation/#accessing-validation-state) for current limitations. ## Auto-save Do not debounce on `command.hasChanges`: it remains true across successive edits. Use actual field values or `commandVersion` in a descendant to schedule work, and clear timers on edits/unmount. Execute through `onExecute` or the form handle. A production autosave also needs serialization of saves, a pending revision, retry/error UI, and a policy for edits arriving during an in-flight save. The current command implementation snapshots its baseline after a completed server request **even on a failed result**, and that snapshot helper skips falsy values. Do not use `hasChanges` as a reliable “successfully persisted” indicator. Keep an application-owned last-successful snapshot/revision if you need that guarantee. [Advanced patterns](/arc/frontend/react/command-form/advanced-patterns/) describes these boundaries without pretending the form supplies an autosave transaction. ## See also * [Working with hooks](/arc/frontend/react/command-form/hooks/) * [Validation](/arc/frontend/react/command-form/validation/) * [Data loading](/arc/frontend/react/command-form/data-loading/) # Working with hooks > Read a CommandForm's command, validation, and execution state from descendant components. A custom button or preview must use the **same command instance** as the fields. CommandForm’s hooks read that instance from React context; they do not construct independent commands. ## useCommandFormContext Call this hook in a descendant of `CommandForm`, never in the component that merely returns the form. This complete component uses the generated `UpdateProfile` from the [overview checkpoint](/arc/frontend/react/command-form/#start-with-a-generated-command). ```tsx import { CommandForm, InputTextField, useCommandFormContext } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; function SaveControls() { const { onExecute, isExecuting, commandResult } = useCommandFormContext(); async function save() { try { await onExecute?.(); } catch { window.alert('The save could not complete. Please try again.'); } } return (
{commandResult && !commandResult.isSuccess &&

The profile was not accepted.

}
); } export function ProfileForm() { return ( value={c => c.name} title="Name" /> value={c => c.email} title="Email" type="email" /> ); } ``` This example uses a click-only `type="button"` action; it has no form-owned submitter for implicit Enter submission. For both keyboard submission and external controls with confirmation and a duplicate guard, use [ProfilePanel](/arc/frontend/react/command-form/form-lifecycle/#reaching-the-form-from-a-parent). `onExecute()` follows the form’s transformation and result-callback path. Calling `commandInstance.execute()` directly bypasses those form callbacks, result display updates, and execution counting. Do not block a retry using the previous `commandResult.isValid`: displayed errors can describe an earlier submission. Useful context members: | Member | Use | | --- | --- | | `commandInstance` | The generated command owned by this form. | | `commandVersion` | Revision advanced by the form’s setters/baseline population; useful for edit-dependent effects. | | `setCommandValues` | Writes command properties and rerenders; this is not baseline initialization or validation. | | `commandResult`, `getFieldError(name)` | Displayed result and the first matching field message. | | `isValid` | Whether the latest applied silent result contains no validation results and no nonempty custom field errors remain; false until a silent result arrives. | | `isAuthorized` | Client identity-role check, not proof of server authorization. | | `isExecuting`, `onExecute` | Execution state and the form’s execution operation. | | `customFieldErrors`, `setCustomFieldError` | Form-local errors keyed by field; nonempty messages block form execution. Undefined or an empty string clears an error. | ## useIsCommandExecuting `useIsCommandExecuting()` is a zero-argument shortcut for the context’s `isExecuting`. It is suitable for a submit button rendered **inside** the form: ```tsx import { useIsCommandExecuting } from '@cratis/arc.react/commands'; export function SubmitButton() { const isExecuting = useIsCommandExecuting(); return ; } ``` The form counts overlapping executions. If two submissions race, the state remains true until both settle; a rejected promise also releases its execution count. This reports activity, not duplicate-submission prevention. Disable UI actions and enforce any required idempotency on the server. ## useCommandInstance `useCommandInstance()` takes **no arguments** and returns the surrounding form’s instance. This complete component adds a preview below the fields: ```tsx import { CommandForm, InputTextField, useCommandInstance } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; function ProfilePreview() { const command = useCommandInstance(); return

Preview: {command.name || 'Unnamed'} — {command.email || 'No email'}

; } export function ProfileForm() { return ( value={c => c.name} title="Name" /> value={c => c.email} title="Email" /> ); } ``` Field edits rerender the context consumer. For programmatic edits, use the context’s setter with an explicit object of property values rather than spreading a generated instance: generated properties are prototype accessors. The setter’s current type is `TCommand`, so an explicit partial update may need an assertion; keep that assertion local and never pass the partial object to `onBeforeExecute` as a replacement command. All these hooks throw outside the provider. Use [formRef/onStateChange](/arc/frontend/react/command-form/form-lifecycle/#reaching-the-form-from-a-parent) for external controls. If you truly need an independent command, use its [generated `.use()` hook](/arc/frontend/react/commands/react-usage/); it does not connect to a separate CommandForm. ## See also * [Validation](/arc/frontend/react/command-form/validation/) * [Data loading](/arc/frontend/react/command-form/data-loading/) * [Form lifecycle](/arc/frontend/react/command-form/form-lifecycle/) # Layouts > Arrange bound fields with CSS, columns, groups, and conditional descendant components. CommandForm preserves child order and binds fields through fragments, HTML elements, and custom layout components. Keep the layout where it reads naturally; you do not need to hoist every field to a direct child. ## Multi-column layout with CommandForm.Column Direct `CommandForm.Column` children are grouped with classes such as `flex`, `flex-column`, `md:flex-row`, `gap-3`, and `flex-1`. Responsive columns require **PrimeFlex or equivalent application CSS defining these utilities**. Arc does not install that stylesheet for you. A PrimeReact theme alone is not a PrimeFlex utility stylesheet. This complete component assumes those utility styles are already loaded and uses the generated `UpdateProfile` from the [overview](/arc/frontend/react/command-form/): ```tsx import { CommandForm, InputTextField } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; export function ProfileColumns() { return (

Profile

value={c => c.name} title="Name" /> value={c => c.email} title="Email" />
); } ``` With compatible utilities, columns stack below the configured medium breakpoint and sit side by side above it; non-column children occupy full-width rows. Without those rules, do not expect the responsive arrangement or gaps. Nested columns still bind their fields, but do not assume a column hidden inside an opaque component will trigger the same **direct-child** column grouping. ## Multi-column layout with CSS Grid (alternative) Use native CSS when you want no utility-library prerequisite. This complete component responds to available width through `auto-fit`: ```tsx import { CommandForm, InputTextField } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; export function ProfileGrid() { return (
value={c => c.name} title="Name" /> value={c => c.email} title="Email" />
); } ``` The grid decides column widths; CommandForm still owns field binding and error display. Use explicit grid areas or spans for asymmetric layouts. ## Grouped fields This **layout fragment** replaces the fields inside either preceding form and uses the same imports: ```tsx
Contact details value={c => c.name} title="Name" /> value={c => c.email} title="Email" />
``` A `fieldset`/`legend` expresses a group without changing the command. Do not nest HTML forms. ## Conditional fields Read the command in a descendant, not the component that creates its provider. This complete component expects a generated `RegisterUser` with string properties `email`, `accountType`, `companyName`, and `taxId`: ```tsx import { CommandForm, InputTextField, SelectField, useCommandInstance } from '@cratis/arc.react/commands'; import { RegisterUser } from './commands/RegisterUser'; function BusinessFields() { const command = useCommandInstance(); if (command.accountType !== 'business') return null; return (
Business details value={c => c.companyName} title="Company name" /> value={c => c.taxId} title="Tax ID" />
); } export function RegistrationForm() { return ( value={c => c.email} title="Email" type="email" /> value={c => c.accountType} title="Account type" options={[{ id: 'personal', name: 'Personal' }, { id: 'business', name: 'Business' }]} optionIdField="id" optionLabelField="name" /> ); } ``` Selecting Business mounts the extra fields; selecting Personal unmounts them. Unmounting a field does **not** clear its command property or remove command validation rules. Define conditional backend rules and decide whether to clear now-inapplicable values. Proxy extraction omits rules guarded by FluentValidation `When`/`Unless` (including `WhenAsync`/`UnlessAsync`, block or chained); these rules are enforced [only on the server](/arc/frontend/react/command-form/validation/#backend-validation). Nullable concept members without a direct `[Required]` annotation omit inferred presence rules (`notEmpty` and `notNull`), while other inferred rules, explicit rules, and annotations still apply under the [validation extraction precedence](/arc/backend/csharp/proxy-generation/validation/#prerequisite-and-extraction-flow). Inspect the generated rules when deciding how hidden values should be validated. A hidden field is not a security boundary Hiding or unmounting a field does not remove its command value or replace server-side validation and authorization. ## Layout and population A mounted field keeps a stable registration. Equivalent inline accessors do not cause repeated population. Changes to the resolved property, `currentValue`, or `noInitialValue` update registration metadata; use `populationKey` when an `initialValue` callback captures other changing semantics. Only committed callbacks are published, including when the source and callback change together. See [Data loading](/arc/frontend/react/command-form/data-loading/#per-field-control). ## See also * [CommandForm overview](/arc/frontend/react/command-form/) * [Validation](/arc/frontend/react/command-form/validation/) * [Customization](/arc/frontend/react/command-form/customization/) # Validation > Distinguish command rules from HTML attributes, and display current validation results in CommandForm. A form can look complete while its command is invalid. Define the rules on the backend, generate supported client rules, and let CommandForm display their results. HTML input attributes and presentation callbacks are not substitutes for command validation. ## Required fields `required` defaults to the inverse of the generated property’s `isOptional` descriptor. An explicit field prop overrides the **control’s required flag**, not the command’s metadata or backend rules. CommandForm renders `
`. Native browser constraint validation therefore does not block submission, including submission through `formRef`. In particular: | Setting | What it does | What enforces the rule | | --- | --- | --- | | `required` | Marks the rendered control as required. | Command payload presence checks reject `null`/`undefined` for non-optional payload properties; they do not reject `''`, whitespace, `0`, or `false`. | | `type="email"` / `type="url"` | Selects the browser input type and input affordances. | An explicit email/URL command rule. | | `NumberField` `min`, `max`, `step` | Configures the numeric control. | Command range/precision rules; typed or programmatically supplied values still need validation. | | A required checkbox | Marks the control, but `false` is a present boolean value. | A rule requiring `true` for consent. | Use FluentValidation `NotEmpty()` (or an appropriate `[Required]` rule) to reject empty strings. Put business invariants on the server even when a matching generated client rule exists. `InputTextField` does **not** expose `minLength`, `maxLength`, or `pattern`; use generated rules or a [custom field](/arc/frontend/react/command-form/custom-fields/) that explicitly supports those attributes. Such attributes still do not turn off the form’s `noValidate` behavior. A field’s display fallback (for example `''` for an undefined text value) is not a command value or baseline. Supply intentional defaults with `initialValues` when needed. ## Validation timing Silent validation runs on initialization and every field edit. The form starts with `isValid: false` until that asynchronous path applies its first result. It is not already validated on the first render. The following are **configuration fragments** inside a component that imports `CommandForm` and the generated `UpdateProfile` from the [overview](/arc/frontend/react/command-form/): ```tsx ``` | Prop | Default | Effect | | --- | --- | --- | | `validateOn` | `'blur'` | Determines when interaction error messages are updated: blur, change, or both. It does not disable silent edit validation. | | `validateAllFieldsOnChange` | `false` | False merges only the interacted field’s displayed messages; true displays the full result. Both paths validate the command, not an isolated field rule. | | `validateOnInit` | `false` | Shows initialization/population errors. False hides those initial messages, not the validation work. | | `autoServerValidate` | `false` | Enables automatic server preflight; see [Auto server validation](/arc/frontend/react/command-form/auto-server-validation/) for request paths and timing. | Use full-result display for cross-field rules when changing one field should update another field’s message. With automatic server validation disabled, silent/interaction validation is client-side. Submission still runs command validation and, when local checks pass, the server pipeline. ## Backend validation This standalone Arc endpoint is a complete command-and-validator example for an already configured Arc host, in whichever backend language you use. It returns the accepted values; it deliberately does **not** persist a profile. A real update also needs an explicit backend write. No event store or Chronicle integration is involved. Persist on the backend, not in a form callback This example only returns accepted values. For a real immediate inline update, prefer a backend [command operation](/arc/backend/csharp/commands/operations/) describing the service write; direct service calls remain supported. Keep validation in validators and UI feedback in form callbacks. Operations execute on the server and do not add operation payloads or recovery details to the TypeScript command result. * C# ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using Cratis.Concepts; using FluentValidation; namespace MyApp.Profiles; public record ProfileName(string Value) : ConceptAs(Value) { public static readonly ProfileName NotSet = new(string.Empty); } public record EmailAddress(string Value) : ConceptAs(Value) { public static readonly EmailAddress NotSet = new(string.Empty); } public class EmailAddressValidator : ConceptValidator { public EmailAddressValidator() => RuleFor(email => email.Value).NotEmpty().EmailAddress(); } [Command] public record UpdateProfile(ProfileName Name, EmailAddress Email) { public ProfileDetails Handle() => new(Name, Email); } public record ProfileDetails(ProfileName Name, EmailAddress Email); public class UpdateProfileValidator : CommandValidator { public UpdateProfileValidator() { RuleFor(command => command.Name).NotEmpty().MinimumLength(3).MaximumLength(100); } } ``` * Java ```java import io.cratis.arc.artifacts.Command; import io.cratis.arc.validation.FluentModelValidator; public record ProfileDetails(String name, String email) { } public final class UpdateProfileRules extends FluentModelValidator { public UpdateProfileRules() { super(UpdateProfile.class); ruleFor("name").notEmpty().length(3, 100); ruleFor("email").notEmpty().emailAddress(); } } @Command public record UpdateProfile(String name, String email) { public ProfileDetails handle() { return new ProfileDetails(name, email); } } ``` * Kotlin ```kotlin package myapp.profiles import io.cratis.arc.artifacts.Command import io.cratis.arc.validation.FluentModelValidator data class ProfileDetails(val name: String, val email: String) class UpdateProfileRules : FluentModelValidator(UpdateProfile::class.java) { init { ruleFor("name").notEmpty().length(3, 100) ruleFor("email").notEmpty().emailAddress() } } @Command data class UpdateProfile(val name: String, val email: String) { fun handle(): ProfileDetails = ProfileDetails(name, email) } ``` * TypeScript (preview) ```typescript import { ConceptAs, field } from '@cratis/fundamentals'; import { command, CommandValidator, ConceptValidator, validator } from '@cratis/arc.core'; export class ProfileName extends ConceptAs { static readonly valueType = String; } export class EmailAddress extends ConceptAs { static readonly valueType = String; } @validator(EmailAddress) export class EmailAddressValidator extends ConceptValidator { constructor() { super(); this.ruleFor(email => email.value).notEmpty().emailAddress(); } } export class ProfileDetails { constructor(readonly name: ProfileName, readonly email: EmailAddress) {} } @command() export class UpdateProfile { @field(ProfileName) name!: ProfileName; @field(EmailAddress) email!: EmailAddress; handle(): ProfileDetails { return new ProfileDetails(this.name, this.email); } } @validator(UpdateProfile) export class UpdateProfileValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.name).notEmpty().minLength(3).maxLength(100); } } ``` The rules land in different places because backend generators project different surfaces to the client. TypeScript’s source preview projects supported fluent validator rules; consult its [validation generator reference](/arc/backend/typescript/proxy-generation/validation/) rather than assuming C# or JVM rule coverage. C# extracts a supported subset from FluentValidation — here a `ConceptValidator` that travels with the value and a `CommandValidator` for the command’s own rule. Arc.Kotlin’s client-projected surface is [shared fluent validation](/arc/backend/kotlin/guides/validation/): a `FluentModelValidator` whose single `init` block declares literal rules over string, collection, and numeric members, which is why the JVM command carries plain `String` members rather than concepts. Anything conditional, asynchronous, or service-dependent stays in an ordinary `CommandValidator` there, server-side. The named values distinguish profile names from email addresses in backend signatures. `EmailAddressValidator` carries the email invariant wherever that concept appears. Arc’s concept-aware `RuleFor` unwraps `ProfileName`, so the command’s string rules still apply. The generated `name` and `email` properties are strings, with the concept’s supported rules attached to `email`; your React inputs need no concept-object conversion. These declarations are grouped for copying; keep each concept in its own application file. Build in Debug with [proxy generation configured](/arc/backend/csharp/proxy-generation/getting-started/), then use the generated class in the [overview form](/arc/frontend/react/command-form/#start-with-a-generated-command). Entering a short name or malformed email should display its rule message on blur. A valid submission returns `{ name, email }` to `onSuccess`. Only supported rules are generated; asynchronous service/database checks remain server-side. Preflight `validate()` skips handler execution and handler-argument resolution, but still runs pipeline filters/validators when it reaches the server. Keep those operations free of unintended side effects and sensitive diagnostic output. A client-side short-circuit does not establish server authorization. See [Backend command validation](/arc/backend/csharp/commands/command-validation/). ## Accessing validation state Read context in a **child** of the form. This complete component shows all displayed validation messages, including results with no field member. `showErrors={false}` hides automatic field errors and form-level exception feedback, including any custom `exceptionDisplayComponent` without invoking it; validation and result state remain unchanged: ```tsx import { CommandForm, InputTextField, useCommandFormContext } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; function ErrorSummary() { const { commandResult } = useCommandFormContext(); const results = commandResult?.validationResults ?? []; if (results.length === 0) return null; return (
    {results.map((result, index) => (
  • {result.reason === 'rule' ? result.message : 'The request could not be validated. Please try again.'}
  • ))}
); } export function ProfileForm() { return ( value={c => c.name} title="Name" /> value={c => c.email} title="Email" /> ); } ``` `getFieldError('email')` returns the first matching message, giving custom errors precedence. Built-in field display also shows that first message; the summary can show the full array. Results without members require form-level display. Translating summary text changes only that summary; it does not sanitize messages rendered independently by custom fields. Keep rule messages safe for end users and translate framework-generated reasons to application wording rather than exposing diagnostics; [validation results](/arc/frontend/core/validation/results/) describes reason metadata. This summary handles validation results only: when hiding automatic feedback, provide your own safe exception feedback too. Current state boundaries: * `isValid` requires the latest applied **silent** result to have an empty `validationResults` array and no nonempty custom field errors to remain. It does not come from displayed `commandResult.isValid`. Even a warning in silent validation makes that form flag false; command execution has its own severity policy. * `isAuthorized` checks the client identity’s roles. It does not certify server authorization. * The form has no public validation-pending state. A prior verdict can remain while newer validation is in flight. Internal ordering rejects a result overtaken by a later **applied** result, not every result for an older edit. * `onFieldChange` fires immediately on edits with previously displayed validation information; it can also fire after blur. Do not use its `validationInfo` as a fresh asynchronous permission to save. * Nonempty errors from `onFieldValidate` and `setCustomFieldError` block form execution and are reported as validation failures. Clearing the last custom error restores retained native feedback; see [custom-error result behavior](/arc/frontend/react/command-form/form-lifecycle/#command-result-callbacks). Raw command execution bypasses this form-local gate, so enforce business rules in command/server validation too. These flags are UI hints, not reservations or security boundaries. Always let execution validate again. ## Validation failures and exceptions Field validation messages remain associated with their fields and continue to use `errorDisplayComponent` when supplied without a custom field container. A validation-only result does not produce form-level exception feedback. With `showErrors` enabled, a result containing both validation failures and exceptions displays the field messages alongside the safe exception message. CommandForm shows exception feedback when `hasExceptions` is true **or** `exceptionMessages` is nonempty, even if those values are inconsistent. It never displays those diagnostic messages or the stack trace automatically. The default text is `An unexpected error occurred. Please try again.`; use [safe exception customization](/arc/frontend/react/command-form/customization/#safe-exception-feedback) to localize it or replace its panel. The `onException` callback still depends on the flag, not the messages; see [exception diagnostics and display](/arc/frontend/react/command-form/form-lifecycle/#exception-diagnostics-and-display). ## Progressive validation Prefer built-in validation for ordinary feedback. If you own an asynchronous preflight UI, tie the verdict to the **edit revision**, clear permission immediately on a new revision, and ignore superseded responses. Depending on the boolean `hasChanges` misses all edits after it first becomes true. This complete child component is an optional replacement for the submit button in the overview. It debounces its own preflight and must be rendered inside CommandForm. Leave `autoServerValidate` off to avoid two competing preflight mechanisms. ```tsx import { useEffect, useState } from 'react'; import { useCommandFormContext } from '@cratis/arc.react/commands'; import { UpdateProfile } from './commands/UpdateProfile'; type Check = { revision: number; valid: boolean }; export function CheckedSubmitButton() { const { commandInstance, commandVersion, isExecuting } = useCommandFormContext(); const [check, setCheck] = useState(); useEffect(() => { let active = true; const revision = commandVersion; const timer = setTimeout(async () => { try { const result = await commandInstance.validate(); if (active) setCheck({ revision, valid: result.isValid && result.isAuthorized && !result.hasExceptions }); } catch { if (active) setCheck({ revision, valid: false }); } }, 500); return () => { active = false; clearTimeout(timer); }; }, [commandInstance, commandVersion]); const pending = check?.revision !== commandVersion; return (

{pending ? 'Checking…' : check?.valid ? 'Ready to submit.' : 'Review the values or try editing again.'}

); } ``` A new form revision immediately invalidates the old button verdict even before its effect runs. Cleanup prevents an older request from overwriting a newer check and prevents updates after unmount. It does not cancel an HTTP request already sent. This example observes edits made through the form; arbitrary direct property assignments are not guaranteed to advance its revision. Preflight can still short-circuit locally and is never an authorization grant. Keyboard or programmatic submission can bypass a disabled button, so execution remains the enforcement point. ## Validation results `validate()` returns a `CommandResult` implementing `ICommandResult`. Use these actual members; there is no `errors` dictionary, `hasErrors()` method, or `getErrorsFor()` method. | Member | Meaning | | --- | --- | | `isSuccess`, `isValid`, `isAuthorized`, `hasExceptions` | Outcome flags. | | `validationResults` | Array of `{ severity, message, members, state, reason, reasonDetail? }`. | | `exceptionMessages`, `exceptionStackTrace` | Exception diagnostics. | | `authorizationFailureReason` | Authorization failure detail. | | `correlationId`, `response` | Correlation identifier and optional typed response. | For exact field matching outside CommandForm, filter `validationResults` by member name and map to messages. Account for casing/nested paths in your own renderer; within the form prefer `getFieldError()` for its field-matching behavior. ## See also * [Auto server validation](/arc/frontend/react/command-form/auto-server-validation/) * [Customization](/arc/frontend/react/command-form/customization/) * [Core command validation](/arc/frontend/core/commands/validation/) * [Form lifecycle](/arc/frontend/react/command-form/form-lifecycle/) # Commands > Execute backend commands from React through the generated proxy's .use() hook, with change tracking, validation, and typed results. Commands represent actions you want to perform against the system. Arc generates their HTTP surface from model-bound commands, and in C# also from discovered controller actions. Either style can use validation and authorization; neither requires Chronicle or event sourcing. ## Overview Commands provide a structured way to: * Encapsulate business operations and state changes * Validate input before execution * Track changes from original values * Integrate with React’s rendering pipeline * Handle authorization and error scenarios ## Quick Start **Backend fragment:** these are the declarations the proxy below is generated from. * C# ```csharp public record OpenDebitAccount(AccountId AccountId, AccountName Name, CustomerId Owner); ``` * Java ```java @Command public record OpenDebitAccount(AccountId accountId, AccountName name, CustomerId owner) { public void handle(@FromServices AccountService accounts) { accounts.open(accountId, name, owner); } } ``` * Kotlin ```kotlin @Command data class OpenDebitAccount(val accountId: AccountId, val name: AccountName, val owner: CustomerId) { suspend fun handle(@FromServices accounts: AccountService) { accounts.open(accountId, name, owner) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; @command() export class OpenDebitAccount { @field(AccountId) accountId!: AccountId; @field(AccountName) name!: AccountName; @field(CustomerId) owner!: CustomerId; @inject(AccountService) handle(accounts: AccountService): Promise { return accounts.open(this.accountId, this.name, this.owner); } } ``` The backend tabs reach the shared generated-command runtime from different starting points. The C# record is a **payload DTO**: it requires an already-defined controller endpoint accepting it, because a record alone is not enough for proxy discovery. The JVM has no controller-based discovery, so the model-bound `@Command` *is* the endpoint. TypeScript’s source preview generates from decorated `@command()` classes with a `handle()` method; see its [generator setup](/arc/backend/typescript/proxy-generation/getting-started/). For a complete model-bound C# example, follow [getting started](/arc/frontend/react/getting-started/). **Consumer fragment:** call this hook inside a component, and execute from its event handler. This example assumes string-backed `AccountId` and `CustomerId`; use Fundamentals `Guid` values instead if the generated model maps Guid-backed concepts. ```tsx import { OpenDebitAccount } from './generated/commands'; export function OpenAccount() { const [command] = OpenDebitAccount.use({ accountId: 'a23edccc-6cb5-44fd-a7a7-7563716fb080', name: 'My Account', owner: '84cda809-9201-4d8c-8589-0be37c6e3f18' }); async function open() { const result = await command.execute(); console.log(result.isSuccess ? 'Opened.' : 'Could not open account.'); } return ; } ``` ## HTTP Headers Command fetches include `httpHeadersCallback` headers configured in [Arc](/arc/frontend/react/arc/). Supply Authorization or application headers there; cookies are browser-managed, not configurable `Cookie` request headers. ## Proxy Generation Commands are automatically generated from your backend using the [proxy generator](/arc/backend/csharp/proxy-generation/). The generator scans HTTP Post actions during compile time and creates TypeScript classes that: * Match your backend command structure * Provide type-safe properties * Include validation support * Offer a `.use()` method for React integration * Track changes automatically See [Proxy Generation](/arc/backend/csharp/proxy-generation/) for setup details. ## Command Result When executed, commands return a `CommandResult` with detailed information about the outcome: ```typescript const result = await command.execute(); if (result.isSuccess) { console.log('Success!', result.response); } else { if (!result.isAuthorized) { // Handle authorization failure } if (!result.isValid) { // Handle validation errors } } ``` For comprehensive details, see [Command Result documentation](/arc/frontend/core/commands/command-result/). ## Topics | Topic | Description | | --- | --- | | [React Usage](/arc/frontend/react/commands/react-usage/) | Using the `.use()` hook in React components (recommended). | | [Data Binding](/arc/frontend/react/commands/data-binding/) | Binding to command properties and managing initial values for change tracking. | | [Validation](/arc/frontend/react/commands/validation/) | Pre-flight validation, progressive validation, and error handling. | | [Command Scope](/arc/frontend/react/commands/scope/) | Tracking changes across multiple commands in composite UIs. | | [Imperative Usage](/arc/frontend/react/commands/imperative-usage/) | Advanced direct instantiation for non-React scenarios. | ## Related Documentation * [CommandForm](/arc/frontend/react/command-form/) - Declarative form component for commands * [Queries](/arc/frontend/react/queries/) - Data retrieval operations * [Core Commands](/arc/frontend/core/commands/) - Lower-level command concepts * [Proxy Generation](/arc/backend/csharp/proxy-generation/) - Setting up command generation # Data binding and initial values > Edit generated command content without confusing React updates with the change-tracking baseline. An edit screen needs two versions of its data: the draft and the baseline used to detect changes. A generated command holds both, but editing a property and accepting a new baseline are different operations. ## Binding to command properties Generated hooks return `[command, setValues, clearValues]`. Prefer `setValues({ property: value })` for React input edits: it updates the named properties and requests a render. Raw assignment notifies command change tracking but does not reliably rerender every edit while `hasChanges` remains true. Neither raw assignment nor the setter runs validation automatically; [CommandForm](/arc/frontend/react/command-form/) adds form-driven validation. Use explicit content objects. Generated public properties are prototype accessors, so `{ ...command }` is not a plain payload snapshot. ## Initial values | Operation | Effect | | --- | --- | | `GeneratedCommand.use(initialValues)` | Seeds values and baseline when the instance is created; later prop changes do not reseed it | | `setValues(partialContent)` | Edits supplied properties and requests a React render; baseline unchanged | | `command.setInitialValues(content)` | Sets supplied values and their baseline, including falsy values | | `command.setInitialValuesFromCurrentValues()` | Snapshots current **truthy** properties; skips `''`, `0`, `false`, null, and undefined | | `command.revertChanges()` | Restores values from the stored baseline | | Hook `clearValues()` | Assigns undefined to properties and requests a render; does not establish a new baseline | | `command.clear()` | Clears properties and baseline; distinct from the hook clearer | `hasChanges` is a comparison flag, not an edit revision or a saved-success flag. Do not use `[command.hasChanges]` as the dependency for per-keystroke validation or autosave. ## Loading data from queries Destructure the query tuple, check readiness/success, and read `.data`. Avoid repeatedly reseeding an editor on observable updates: that can erase the user’s draft. One simple pattern is to key the query-owning loader by the selected identity and mount its editor only after a successful result matches that identity. Keying only the editor can seed it with the previous selection’s retained query result. This illustrative pair assumes generated `GetUserProfile` and `UpdateProfile` proxies whose content has string `userId`, `firstName`, and `email` properties: ```tsx import { useState } from 'react'; import { GetUserProfile } from './generated/GetUserProfile'; import { UpdateProfile } from './generated/UpdateProfile'; type Profile = { userId: string; firstName: string; email: string }; function Editor({ profile }: { profile: Profile }) { const [command, setValues] = UpdateProfile.use({ userId: profile.userId, firstName: profile.firstName, email: profile.email }); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(''); async function save() { setSaving(true); try { const result = await command.execute(); setMessage(result.isSuccess ? 'Saved.' : 'Save failed; your draft is still displayed.'); } finally { setSaving(false); } } return ( { event.preventDefault(); void save(); }}> setValues({ firstName: event.target.value })} /> setValues({ email: event.target.value })} />

{message}

); } function ProfileLoader({ userId }: { userId: string }) { const [profile] = GetUserProfile.use({ userId }); if (!profile.isReady || profile.isPerforming) return

Loading…

; if (!profile.isSuccess || profile.data.userId !== userId) { return

Could not load the selected profile.

; } return ; } export function ProfileEditor({ userId }: { userId: string }) { return ; } ``` Changing selection remounts the query loader and editor together, discarding the previous draft; confirm navigation first if unsaved edits must be preserved. The identity check also rejects a mismatched response. The editor consumes the incoming profile only on its initial mount. A deliberate refresh/reload must decide whether to discard edits first. For a form-managed alternative, see [loading form data](/arc/frontend/react/command-form/data-loading/). ## Resetting to initial values If you retain an explicit baseline object, `setValues(baseline)` restores the draft without accepting a new baseline. To accept newly loaded content as the baseline and reliably render it, call `command.setInitialValues(content)` followed by `setValues(content)`. Supply explicit properties, not a spread command instance. ## Execution baseline limitations Current `execute()` calls `setInitialValuesFromCurrentValues()` after every completed server request, **including unsuccessful results**. Client-validation short circuits do not reach that step. Combined with the truthy-only snapshot behavior, `hasChanges` is not a reliable “unsaved after failure” indicator for all values. Keep authoritative save status in the returned `CommandResult` and preserve drafts independently when failure recovery matters. Success-only baseline acceptance and falsy-value snapshots are runtime follow-up candidates. Do not disable all retries solely because `hasChanges` became false after rejection. ## Tracking changes across multiple commands A [command scope](/arc/frontend/react/commands/scope/) tracks its own registered commands and can execute those with changes. It does not recursively execute nested scopes or aggregate their change/performing flags. Compose a toolbar under the provider with `useCommandScope()`; `CommandScope` does not accept a render-function child. Continue with [React hook usage](/arc/frontend/react/commands/react-usage/) and [validation](/arc/frontend/react/commands/validation/). # Imperative Usage > Create and execute command proxies directly, outside React hooks, for service layers and other non-component code. While the [React Hook Usage](/arc/frontend/react/commands/react-usage/) is the recommended approach for React components, there are scenarios where you need more direct control or are working outside of React’s component lifecycle. This guide covers imperative command usage. ## Overview Imperative usage involves directly instantiating and manipulating command objects without React hooks. This is useful for: * **Non-React Code**: Service layers, utility functions, or vanilla JavaScript * **Event Handlers**: Complex operations in event handlers * **Testing**: Unit tests and integration tests * **Advanced Scenarios**: Custom command orchestration or batching ## Basic Imperative Usage Directly instantiate and execute a command: ```typescript import { OpenDebitAccount } from './generated/commands'; const command = new OpenDebitAccount(); command.accountId = 'a23edccc-6cb5-44fd-a7a7-7563716fb080'; command.name = 'My Account'; command.owner = '84cda809-9201-4d8c-8589-0be37c6e3f18'; const result = await command.execute(); if (result.isSuccess) { console.log('Account opened successfully'); } ``` ## Setting Initial Values For change tracking to work, set initial values using `setInitialValues()`: ```typescript const command = new OpenDebitAccount(); command.setInitialValues({ accountId: 'a23edccc-6cb5-44fd-a7a7-7563716fb080', name: 'My Account', owner: '84cda809-9201-4d8c-8589-0be37c6e3f18', }); // At this point hasChanges is false console.log(command.hasChanges); // false command.name = 'My other account'; // Now hasChanges is true console.log(command.hasChanges); // true ``` ## Use Cases ### Service Layer Functions ```typescript export class AccountService { async openAccount(accountData: { accountId: string; name: string; owner: string }) { const command = new OpenDebitAccount(); command.accountId = accountData.accountId; command.name = accountData.name; command.owner = accountData.owner; const result = await command.execute(); if (!result.isSuccess) { throw new Error( 'Failed to open account: ' + result.exceptionMessages.join(', '), ); } return result.response; } } ``` ### Batch Operations ```typescript async function batchOpenAccounts( accounts: Array<{ accountId: string; name: string; owner: string }>, ) { const results = await Promise.all( accounts.map(async (data) => { const command = new OpenDebitAccount(); command.accountId = data.accountId; command.name = data.name; command.owner = data.owner; return command.execute(); }), ); const successful = results.filter((r) => r.isSuccess); const failed = results.filter((r) => !r.isSuccess); return { successful: successful.length, failed: failed.length, failedReasons: failed.map((r) => r.exceptionMessages), }; } ``` ### Event Handler ```typescript async function handleAccountCreation(event: CustomEvent) { const { accountId, name, owner } = event.detail; const command = new OpenDebitAccount(); command.accountId = accountId; command.name = name; command.owner = owner; const result = await command.execute(); if (result.isSuccess) { // Trigger success event window.dispatchEvent( new CustomEvent('account-opened', { detail: result.response, }), ); } else { // Trigger error event window.dispatchEvent( new CustomEvent('account-error', { detail: result.exceptionMessages, }), ); } } ``` ### Testing ```typescript import { describe, it, expect } from 'vitest'; import { OpenDebitAccount } from './generated/commands'; describe('OpenDebitAccount', () => { it('should successfully open an account with valid data', async () => { const command = new OpenDebitAccount(); command.accountId = 'test-account-id'; command.name = 'Test Account'; command.owner = 'test-owner-id'; const result = await command.execute(); expect(result.isSuccess).toBe(true); expect(result.isValid).toBe(true); expect(result.isAuthorized).toBe(true); }); it('should track changes correctly', () => { const command = new OpenDebitAccount(); command.setInitialValues({ accountId: 'test-id', name: 'Original Name', owner: 'owner-id', }); expect(command.hasChanges).toBe(false); command.name = 'Modified Name'; expect(command.hasChanges).toBe(true); command.name = 'Original Name'; expect(command.hasChanges).toBe(false); }); it('should validate without executing', async () => { const command = new OpenDebitAccount(); command.accountId = ''; command.name = ''; command.owner = ''; const result = await command.validate(); expect(result.isValid).toBe(false); expect(result.validationResults.length).toBeGreaterThan(0); }); }); ``` ## Validation Commands can be validated imperatively without execution: ```typescript const command = new OpenDebitAccount(); command.accountId = 'test-id'; command.name = ''; // Invalid - empty name command.owner = 'owner-id'; const validationResult = await command.validate(); if (!validationResult.isValid) { console.error('Validation failed:'); validationResult.validationResults.forEach((error) => { console.error(`- ${error.members.join(', ')}: ${error.message}`); }); } ``` See [Validation](/arc/frontend/react/commands/validation/) for more details. ## Using with Factories Create factory functions for common command patterns: ```typescript function createAccountCommand(data: { accountId: string; name: string; owner: string; initialBalance?: number; }): OpenDebitAccount { const command = new OpenDebitAccount(); command.accountId = data.accountId; command.name = data.name; command.owner = data.owner; // Set initial values for change tracking command.setInitialValues(data); return command; } // Usage const command = createAccountCommand({ accountId: crypto.randomUUID(), name: 'Savings Account', owner: 'user-123', }); await command.execute(); ``` ## Error Handling Always handle errors properly with imperative usage: ```typescript async function executeCommand(command: OpenDebitAccount) { try { const result = await command.execute(); if (!result.isSuccess) { if (!result.isAuthorized) { throw new Error('Unauthorized'); } if (!result.isValid) { const errors = result.validationResults.map((v) => v.message).join(', '); throw new Error(`Validation failed: ${errors}`); } if (result.hasExceptions) { throw new Error(result.exceptionMessages.join(', ')); } } return result.response; } catch (error) { console.error('Command execution failed:', error); throw error; } } ``` ## Integration with React (Advanced) While React hooks are preferred, you can use imperative commands within React components for specific scenarios: ```typescript import { useState } from 'react'; import { OpenDebitAccount } from './generated/commands'; export const AccountCreator = () => { const [processing, setProcessing] = useState(false); const handleQuickCreate = async () => { setProcessing(true); // Create and execute command imperatively const command = new OpenDebitAccount(); command.accountId = crypto.randomUUID(); command.name = 'Quick Account'; command.owner = 'default-owner'; try { const result = await command.execute(); if (result.isSuccess) { alert('Account created!'); } } finally { setProcessing(false); } }; return ( ); }; ``` Prefer the hook in React components Use the [React hook approach](/arc/frontend/react/commands/react-usage/) in components for automatic re-rendering and integration with React’s lifecycle. ## Best Practices 1. **Use React Hooks in Components**: Only use imperative approach when necessary 2. **Handle All Result States**: Check `isSuccess`, `isValid`, `isAuthorized` 3. **Set Initial Values**: Call `setInitialValues()` when you need change tracking 4. **Error Handling**: Always handle errors appropriately 5. **Type safety**: Keep the generated command and response types when calling from TypeScript 6. **Testing**: Imperative usage is excellent for unit testing 7. **Documentation**: Document why imperative usage was chosen over hooks ## When NOT to Use Imperative Usage Avoid imperative usage when: * You’re in a React component (use hooks instead) * You need automatic re-rendering * You want React lifecycle integration * Building forms (use [CommandForm](/arc/frontend/react/command-form/)) ## See Also * [Commands Overview](/arc/frontend/react/commands/) * [React Hook Usage](/arc/frontend/react/commands/react-usage/) - Recommended approach for React * [Data Binding](/arc/frontend/react/commands/data-binding/) * [Validation](/arc/frontend/react/commands/validation/) * [CommandForm](/arc/frontend/react/command-form/) * [Core Commands](/arc/frontend/core/commands/) - Lower-level command concepts # React command hooks > Use the generated command tuple for explicit input updates, baseline initialization, and execution feedback. A generated command’s static `.use()` method creates a command instance for a mounted component, configures it from the nearest Arc context, and registers it with the nearest command scope. ## Return tuple | Position | Value | Contract | | --- | --- | --- | | 0 | `command` | Generated instance, properties, execution, and change tracking | | 1 | `setValues` | Updates supplied content properties and requests a React render | | 2 | `clearValues` | Assigns undefined to all content properties and requests a render; does not reset the baseline | Omit unused trailing tuple values when destructuring. Do not treat the tuple itself as a command. ## With initial values Pass an explicit content object to seed values and baseline when the hook creates its instance. Initial values are not reapplied on later renders. Required generated properties are not automatically initialized; an identity, quantity, or other required value must come from your application. This illustrative one-operation editor assumes a generated `RenameAccount` command with string `accountId` and `name` properties. If your backend uses a Guid-based concept, use the generated Fundamentals `Guid` type instead of a string: ```tsx import { useState } from 'react'; import { RenameAccount } from './generated/RenameAccount'; export function RenameAccountForm({ accountId, name }: { accountId: string; name: string }) { const [command, setValues] = RenameAccount.use({ accountId, name }); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(''); async function save() { setSaving(true); try { const result = await command.execute(); setMessage(result.isSuccess ? 'Saved.' : 'Could not save. Check your inputs and permissions.'); } finally { setSaving(false); } } return (
{ event.preventDefault(); void save(); }}>

{message}

); } ``` Key/remount the editor when switching accounts, or deliberately load a new baseline after deciding what to do with edits. See [data binding](/arc/frontend/react/commands/data-binding/) for query-fed forms and reset semantics. ## React re-rendering Use the tuple setter for reliable per-edit rendering. Generated setters notify property tracking, but raw assignment does not reliably rerender every edit while `hasChanges` remains true. The hook does not automatically validate each assignment or provide an execution-loading state; use explicit state as above or [CommandForm](/arc/frontend/react/command-form/). `hasChanges` compares draft content with the baseline. It is not an edit revision, a server-save status, or a guaranteed retry gate after failure. Current execution snapshots truthy values after completed server requests even when unsuccessful; see [baseline limitations](/arc/frontend/react/commands/data-binding/#execution-baseline-limitations). ## Available operations * `execute(allowedSeverity?, ignoreWarnings?)` validates and attempts execution; inspect its `CommandResult`. * `validate()` provides preflight feedback, potentially returning locally before server authorization. * `validateClientSide()` checks local rules synchronously. * `setInitialValues(content)` accepts explicit values as the baseline. * `revertChanges()` restores baseline values. Continue with [validation](/arc/frontend/react/commands/validation/), [command scopes](/arc/frontend/react/commands/scope/), or [imperative usage](/arc/frontend/react/commands/imperative-usage/). # Command scope > Coordinate commands registered in one scope and understand the limits of nested state and execution callbacks. Use a command scope when a toolbar should execute the changed commands registered in one section. It is a coordination mechanism, not a transaction or a recursive “save the entire page” operation. ## Using command scope in React Commands created by generated `.use()` hooks register with the nearest scope. `` supplies a default scope; add a local one when you want a separate toolbar boundary. Pass state callbacks to make toolbar updates explicit. This composition fragment accepts your editor components as children: ```tsx import { useState, type ReactElement } from 'react'; import { CommandScope, useCommandScope } from '@cratis/arc.react/commands'; function Toolbar({ changed, busy }: { changed: boolean; busy: boolean }) { const scope = useCommandScope(); return ( ); } export function EditorScope({ children }: { children: ReactElement }) { const [changed, setChanged] = useState(false); const [busy, setBusy] = useState(false); return ( {children} ); } ``` The toolbar must be a descendant of the scope it consumes. The provider renders elements, not a function child. For rejection recovery, inspect results and offer explicit command-level retry: [baseline limitations](/arc/frontend/react/commands/data-binding/#execution-baseline-limitations) mean a rejected request can leave no tracked changes. ## Hierarchical scopes Nested scopes register with their nearest parent, but aggregation differs by member: | Member | Current behavior | | --- | --- | | `hasChanges` | Own registered commands only | | `isPerforming` | This scope’s `execute()` operation only | | `execute()` | Sequentially executes own changed commands, not child scopes | | `revertChanges()` | Restores own commands’ values, not child scopes; does not guarantee descendant inputs rerender | | `validationFailures`, `exceptions` | Maps for own commands executed through the scope | | `hasValidationFailures`, `hasExceptions` | Getters include child scopes | | `aggregatedValidationFailures`, `aggregatedExceptions` | Flatten own and child stored failures | | `parent`, `addChildScope(scope)` | Hierarchy registration, not a promise of all-state notification propagation | | `addCommand(command)` | Registers property-change tracking | | `addQuery(query)` | Stores a reference; does not track query loading | Use [QueryScope](/arc/frontend/react/queries/scope/) for query activity. Getter aggregation does not itself trigger React or MobX updates. Nested change/performing aggregation, notification propagation, and recursive execution are runtime follow-up candidates, not supported guarantees. For a visible reset, have each editor call its hook tuple’s `setValues(baseline)` with an explicit baseline, as shown in [resetting to initial values](/arc/frontend/react/commands/data-binding/#resetting-to-initial-values). A scope-wide reset needs an explicit notification to every editor so each invokes its setter. Calling `scope.revertChanges()` alone can leave displayed inputs stale even though command values reverted. ## Execution callbacks Callbacks run **inside `scope.execute()`** for each of its changed commands. They do not intercept direct `command.execute()` calls or form submissions, and parent callbacks do not intercept child-scope execution. | Callback | When called during scope execution | | --- | --- | | `onBeforeExecute(command)` | Before a command executes | | `onSuccess(command, result)` | Successful result | | `onFailed(command, result)` | Any unsuccessful result | | `onException(command, result)` | Result has exceptions | | `onUnauthorized(command, result)` | Result is unauthorized | | `onValidationFailure(command, result)` | Result is invalid | Failure-specific callbacks can accompany `onFailed`. Stored failures for a command are cleared immediately before the scope executes it again, not whenever anyone calls that command. Commands skipped because they have no changes retain their previous scope entries. ## Validation failures and exceptions `validationFailures` maps commands to `ValidationResult[]`; `exceptions` maps commands to `string[]`. Read `aggregatedValidationFailures` and `aggregatedExceptions` for flattened hierarchy views. Update UI through explicit execution/state callbacks rather than assuming these mutable maps are observable. ## Using command scope in view models MVVM can inject the nearest `ICommandScope`. Reading its getters and calling `execute()` are supported, but making a view model observable does not make the injected scope’s internals observable. Bridge the state you display through an explicit notification/state path. See [MVVM context](/arc/frontend/react/mvvm/mvvm-context/) and [command results](/arc/frontend/core/commands/command-result/). # Command validation in React > Show preflight feedback without stale submit permission, native form navigation, or false security guarantees. A field can be valid when you start checking it and different by the time the answer arrives. For raw command hooks, treat validation as feedback tied to an edit revision, not as a lasting permission to submit. [CommandForm](/arc/frontend/react/command-form/) is the preferred form-managed alternative. ## How it works `validate()` first checks client rules and required values. A failure can return locally without checking server authorization. Otherwise the backend runs filters but skips handler execution and handler-argument resolution. Filter/validator code must itself be read-only and avoid sensitive messages; “the handler did not run” is not a blanket side-effect or confidentiality guarantee. `execute()` validates again. Inspect its final `CommandResult` even after a successful preflight. See [core validation](/arc/frontend/core/commands/validation/). ## Progressive and debounced validation This illustrative component assumes a generated `UpdateContact` command with string `email` and `phone` properties and no other required input. It validates the displayed draft after a short delay, discards stale answers, clears old messages on edits, and prevents native form navigation: ```tsx import { useEffect, useRef, useState } from 'react'; import { ValidationResult } from '@cratis/arc/validation'; import { UpdateContact } from './generated/UpdateContact'; export function ContactEditor() { const [command, setValues] = UpdateContact.use({ email: '', phone: '' }); const [revision, setRevision] = useState(0); const currentRevision = useRef(0); const [errors, setErrors] = useState([]); const [saving, setSaving] = useState(false); const [status, setStatus] = useState(''); function edit(values: { email?: string; phone?: string }) { currentRevision.current++; setValues(values); setErrors([]); setStatus(''); setRevision(currentRevision.current); } useEffect(() => { let active = true; const timer = setTimeout(async () => { const result = await command.validate(); if (active && currentRevision.current === revision) { setErrors(result.validationResults); } }, 300); return () => { active = false; clearTimeout(timer); }; }, [command, revision]); async function submit() { currentRevision.current++; setSaving(true); try { const result = await command.execute(); setErrors(result.validationResults); setStatus(result.isSuccess ? 'Saved.' : 'Save failed. Check your inputs and permissions.'); } finally { setSaving(false); } } const errorFor = (member: string) => errors.find(error => error.members.includes(member))?.message; return (
{ event.preventDefault(); void submit(); }}>

{errorFor('email')}

{errorFor('phone')}

{status}

); } ``` The submit button does not rely on cached preflight permission. Required rules remain on the backend; the sample’s HTML does not add Arc validation rules. Map framework-generated rejection reasons to application copy before showing them in production; see [validation results](/arc/frontend/core/validation/results/). ## Validation on blur For blur-only feedback, call `validate()` from the field’s blur handler and use the same revision guard. Find messages by `result.validationResults.filter(item => item.members.includes(fieldName))`. Replace that field’s previous messages with the new list, **including an empty list**, even when a different field still fails. There are no `CommandResult.hasErrors` or `getErrorsFor` helpers. ## Common mistakes * `[command.hasChanges]` is not a per-edit dependency: the flag can remain true across many edits. * Raw property assignment does not guarantee every React render or automatic validation. Use the tuple setter for edits. * Preflight success can go stale. Do not reuse it after a draft or identity change. * `setValues()` edits content; it does not reset the baseline. A completed rejected server execution can change that baseline today. See [data binding](/arc/frontend/react/commands/data-binding/#execution-baseline-limitations). * Default execution allows warnings. For confirmation-before-warning-override, use the explicit [severity workflow](/arc/frontend/core/validation/severity-filtering/), not `execute()` followed by a question. Continue with [command results](/arc/frontend/core/commands/command-result/) and [server validation](/arc/backend/csharp/commands/command-validation/). # Dialogs > Separate dialog rendering from requests and correctly handle result/response tuples. Arc manages dialog visibility and request/response flow without choosing your component library. You provide the rendering; a caller awaits the user’s decision. Components and PrimeReact are optional renderers, not Arc prerequisites. ## Custom dialogs The following contract checkpoint uses a native nonmodal `` to keep the request/response example self-contained. For production modal UI, use an accessible renderer with focus management, dismissal, and labeling appropriate to your application, such as the optional [Components dialogs](/components/). ```tsx import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; type NameRequest = { currentName: string }; function NameDialog({ currentName }: NameRequest) { const { closeDialog } = useDialogContext(); return (

Use {currentName}?

); } export function Feature() { const [NameDialogWrapper, showNameDialog] = useDialog(NameDialog); async function choose() { const [result, response] = await showNameDialog({ currentName: 'Ada' }); if (result === DialogResult.Ok && response !== undefined) { console.log(response); } } return ( <> ); } ``` React `useDialog(Component)` returns **three** elements: `[Wrapper, showDialog, context]`. The wrapper must be rendered; it mounts the dialog only while visible. `showDialog(input)` resolves to `[DialogResult, response?]`, not the response alone. Inside the dialog, use `useDialogContext()` or read request props. Notice the generic order differs between these two hooks. `closeDialog(result, response?)` resolves the pending call. A cancellation may have no response, so check both the result and optional value. Do not open the same hook concurrently: it stores one pending resolver. ## Confirmation dialogs `DialogButtons` supports `Ok`, `OkCancel`, `YesNo`, and `YesNoCancel`. `DialogResult` supports `None`, `Ok`, `Yes`, `No`, and **`Cancelled`**, not `Cancel`. `useConfirmationDialog(title?, message?, buttons?)` returns `[showConfirmation]`. The delegate accepts optional overrides and resolves to **`DialogResult` only**, unlike custom dialog calls. ### Defining the confirmation dialog This minimal renderer handles every button configuration. As above, the nonmodal native presentation is a contract example, not a complete modal design: ```tsx import { ConfirmationDialogRequest, DialogButtons, DialogResult, DialogComponents, useConfirmationDialog, useDialogContext } from '@cratis/arc.react/dialogs'; function ConfirmationDialog() { const { request, closeDialog } = useDialogContext(); const yesNo = request.buttons === DialogButtons.YesNo || request.buttons === DialogButtons.YesNoCancel; const cancel = request.buttons === DialogButtons.OkCancel || request.buttons === DialogButtons.YesNoCancel; return (

{request.message}

{yesNo ? <> : } {cancel && }
); } function ConfirmButton() { const [showConfirmation] = useConfirmationDialog('Continue?', 'Use these settings?', DialogButtons.YesNoCancel); async function confirm() { const result = await showConfirmation(); if (result === DialogResult.Yes) console.log('Confirmed'); } return ; } export const ConfirmationExample = () => ( ); ``` `DialogComponents` supplies and mounts your shared confirmation renderer. Without a configured renderer there is no usable confirmation UI; Arc does not supply a visual library automatically. ## Busy indicator dialogs Configure a `busyIndicator` renderer on `DialogComponents`. Inside it, read `useDialogContext()` for `request.title` and `request.message`. `useBusyIndicator(title?, message?)` returns `[showBusyIndicator, closeBusyIndicator]`. Close it in `finally` after your application-owned operation, including failures. Busy UI is not cancellation or a guarantee that the operation succeeded. Continue with [MVVM dialogs](/arc/frontend/react/mvvm/dialogs/) for request classes and the `IDialogs` service. # Getting started (React) > List authors and register a new author with generated Arc proxies, Arc's CommandForm, and Cratis Components fields in a standalone React app. You have a backend that registers authors and streams an author list. Let’s put a screen in front of it without writing a second API contract in TypeScript, and without wiring `value`, `onChange`, pending state, and result handling by hand for every input. Arc’s generated proxies provide the request types and React hooks. Arc’s `CommandForm` owns the command instance, its execution, and its feedback. Cratis Components supplies the styled, accessible fields that go inside it. Chronicle is not required. This checkpoint uses the C# standalone backend and its generated paths. If your backend is the TypeScript source preview, follow its [React proxy walkthrough](/arc/backend/typescript/getting-started/continue-in-the-browser/) first; its generator and output paths differ. By the end of this page you’ll type a name, submit it, and watch the author list update without a refresh. ## Prerequisites Complete [Your first command and query](/arc/backend/csharp/getting-started/your-first-command/) and generate its proxies with a Debug build. You need a Vite + React 19 app with `react`, `react-dom`, `@cratis/fundamentals`, `@cratis/arc`, and `@cratis/arc.react` installed. Add Components and its required peers: ```bash npm install @cratis/components@^4 reflect-metadata@0.2.2 tsyringe@4.10.0 ``` Keep `@cratis/arc` and `@cratis/arc.react` on the version your generated proxies were produced with, within Components’ peer range (`>=20.3.1 <23`). See [Components setup](/components/getting-started/) for strict installers and other options. To start without Components, see [Without Components](#without-components). The checkpoint below imports from `./Authors/RegisterAuthor` and `./Authors/Author`, where the backend setup’s source-file grouping places the `AllAuthors` query beside the `Author` type. Without `CratisProxiesUseSourceFileAsOutputFile`, each query gets its own file, so import `AllAuthors` from `./Authors/AllAuthors` instead. Match the paths to your configured output directory and namespace mapping. There is **no generated `Bindings` startup file**. ## Load the Components styles Import the Components stylesheets once, at the top of your existing entry point, with `tokens` first: src/main.tsx (excerpt) ```tsx import 'reflect-metadata'; import '@cratis/components/tokens'; import '@cratis/components/styles'; import '@cratis/components/theme'; ``` `tokens` supplies shared CSS variables, `styles` supplies component structure, and `theme` adds the baseline appearance, including dark mode. The stylesheet subpaths ship no type declarations, so current TypeScript releases report `TS2882` for each import. Declare them once in `src/cratis-components-styles.d.ts`: src/cratis-components-styles.d.ts ```typescript declare module '@cratis/components/tokens'; declare module '@cratis/components/styles'; declare module '@cratis/components/theme'; ``` ## Mount Arc and the author screen Put this component in `App.tsx` and render it from your entry point: src/App.tsx ```tsx import { useState } from 'react'; import { Guid } from '@cratis/fundamentals'; import { Arc } from '@cratis/arc.react'; import { CommandForm, useIsCommandExecuting } from '@cratis/arc.react/commands'; import { CratisComponentsProvider } from '@cratis/components'; import { InputTextField } from '@cratis/components/CommandForm'; import { AllAuthors } from './Authors/Author'; import { RegisterAuthor } from './Authors/RegisterAuthor'; function RegisterButton() { const isExecuting = useIsCommandExecuting(); return ; } function Authors() { const [authors] = AllAuthors.use(); const [id, setId] = useState(() => Guid.create()); const [message, setMessage] = useState(''); return (

Authors

{!authors.isReady || authors.isPerforming ?

Loading authors…

: !authors.isSuccess ?

Could not load authors.

:
    {authors.data.map(author => (
  • {author.name}
  • ))}
} key={String(id)} command={RegisterAuthor} initialValues={{ id }} onSuccess={() => { setMessage('Author registered.'); setId(Guid.create()); }} onFailed={() => setMessage('Registration failed. Check the name and your permissions.')}> value={c => c.name} title="Name" />

{message}

); } export const App = () => ( ); ``` Here is what you did not write. `CommandForm` creates one `RegisterAuthor` instance from the generated class and seeds it from `initialValues`. `InputTextField` binds to `name` through the typed accessor `c => c.name`, so there is no `value`/`onChange` pair to keep in sync. Submitting the form executes the command, shows validation messages beside the field they belong to, and shows a safe message when the server throws. `RegisterButton` renders **inside** the form, so `useIsCommandExecuting()` reads that form’s in-flight state; the same hook called in `Authors` would be outside the form and throw. The backend requires both `Id` and `Name`. Its `AuthorId` concept maps to a Fundamentals `Guid` in the generated command. Create an ID **once per new registration**, keep it while editing or retrying, and generate the next one only after success. The user never types the ID, so it belongs in `initialValues`. Changing `initialValues` after mount does not repopulate the form, which is why the form is keyed by the ID: a new ID after success mounts a fresh form with an empty name. `` initializes the runtime bindings and provides command, identity, messaging, and query-cache contexts. `CratisComponentsProvider` supplies Components’ locale and labels; it does not replace ``. With no origin configured, requests target your current origin, so the Vite dev server must forward Arc’s routes to the backend. Add both prefixes to your existing `vite.config.ts`, pointing at the port the backend checkpoint uses: vite.config.ts (excerpt) ```typescript server: { proxy: { '/api': { target: 'http://localhost:5000' }, '/.cratis': { target: 'http://localhost:5000', ws: true } } } ``` `/api` carries the command and query requests; `/.cratis` carries the observable-query stream (Server-Sent Events by default) and identity. See [Vite configuration](/arc/frontend/react/vite-configuration/) for the other options. ## Check the round trip Start the backend and frontend. You should see the author list, enter a name, and see “Author registered.” after a successful submission, with an empty name field ready for the next author. The list updates when the backend’s `Observe()` source emits the changed collection: MongoDB change streams, or EF Core’s `SaveChanges` notifications in the same host. Arc transports those emissions; executing a command alone does not create a persistence subscription. ``` CommandForm with RegisterAuthor Arc command handler MongoDB or EF Core Observe source AllAuthors hook Author list ``` This backend accepts a blank name The backend checkpoint has no validation rules yet, so a blank name is registered. When you add rules on the backend, `CommandForm` shows their messages beside the field without further frontend code. Keep authoritative rules on the backend; see [Command form validation](/arc/frontend/react/command-form/validation/). ## Without Components Components is optional for Arc. If you don’t want the Components package, stylesheets, and provider, Arc ships its own basic field set that works inside the same `CommandForm`. That is the shorter route: skip the Components install and styles, remove `CratisComponentsProvider`, and take `InputTextField` from Arc instead: ```tsx import { CommandForm, InputTextField, useIsCommandExecuting } from '@cratis/arc.react/commands'; ``` The rest of `App.tsx` stays the same. See [CommandForm field types](/arc/frontend/react/command-form/field-types/) for the other built-in fields. ### Own the whole sequence yourself When an application must control every step itself, such as a design system whose inputs cannot be wrapped as command form fields, you can drive the generated command hook directly. This is the longer route: you bind every input and track the pending state and the result yourself, which `CommandForm` does for you above. The component replaces `Authors` from the Components version: ```tsx import { useState } from 'react'; import { Guid } from '@cratis/fundamentals'; import { AllAuthors } from './Authors/Author'; import { RegisterAuthor } from './Authors/RegisterAuthor'; function Authors() { const [authors] = AllAuthors.use(); const [id, setId] = useState(() => Guid.create()); const [command, setValues] = RegisterAuthor.use({ id, name: '' }); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(''); async function register() { setSaving(true); try { const result = await command.execute(); if (result.isSuccess) { const nextId = Guid.create(); setId(nextId); command.setInitialValues({ id: nextId, name: '' }); setValues({ id: nextId, name: '' }); setMessage('Author registered.'); } else { setMessage('Registration failed. Check the name and your permissions.'); } } finally { setSaving(false); } } return (

Authors

{!authors.isReady || authors.isPerforming ?

Loading authors…

: !authors.isSuccess ?

Could not load authors.

:
    {authors.data.map(author => (
  • {author.name}
  • ))}
}
{ event.preventDefault(); void register(); }}>

{message}

); } ``` Mount it with ``. The hook reads initial values when it creates the command, not on every render, so after success the component resets both the change-tracking baseline and the current values to the next ID. Validation messages are not displayed here; read them from the result yourself. See [Commands in React](/arc/frontend/react/commands/react-usage/) for the hook contract. ## Parameterized queries When you add a generated `BooksForAuthor` query with an `authorId` parameter, pass an argument object, not the ID directly. This illustrative component fragment assumes that query and its book model already exist: ```tsx const [books] = BooksForAuthor.use({ authorId }); ``` Only enumerable queries expose paging variants. See the [hook signature reference](/arc/frontend/react/queries/usage/) before switching query shapes. ## Where to go next * [Command forms](/arc/frontend/react/command-form/) cover field types, validation timing, loading form data, and lifecycle callbacks. * [Components](/components/) adds `CommandDialog`, data tables, and other command and query surfaces on top of the same proxies. * [Build your first full-stack slice](/arc/tutorial/first-slice/) opens the same command in a `CommandDialog` and threads the author domain through the tutorial. * [MVVM with React](/arc/frontend/react/mvvm/) separates larger screens from their view logic. * [Build a full-stack feature](/build-a-full-app/) continues the complete application workflow. # Identity > Use identity in React: the identity provider context, useIdentity(), role checks, RequireRole, typed details, and refreshing. The React implementation of identity is built on top of what you find the [core](/arc/frontend/core/identity/). It provides an encapsulation that feels more natural to a React application. ## HTTP Headers Identity fetches include `httpHeadersCallback` headers configured through [Arc](/arc/frontend/react/arc/). Use this for Authorization or application headers, not a browser `Cookie` header. Cookies are browser-managed. The identity comes from `/.cratis/me`, which derives it from the authenticated request. In the browser it is UI data that the user can change; authorization belongs on the server. ## Identity provider context `` already supplies an identity provider. Use a separate provider only for deliberate configuration, such as a generated details constructor; render it beneath Arc and explicitly preserve any headers you need. Identity request settings are global, so nested providers are not independent authenticated sessions. For the default identity shape, use the provider already included in your top-level `Arc`: ```tsx import { Arc } from '@cratis/arc.react'; export const App = () => (
Your application content
); ``` This context can then be used anywhere by consuming the React context directly: ```typescript import { IdentityProviderContext } from '@cratis/arc.react/identity'; export const SomeComponent = () => { return ( {({ identity, isLoading }) => (

{isLoading ? 'Loading…' : identity.name}

)}
); }; ``` > The raw context shape is `{ identity, detailsConstructor, isLoading, clearIdentity }`. Details live at `value.identity.details`, not `value.details`. The default generic details type is `object`; prefer a typed `useIdentity` consumer. ### Refreshing Sometimes you need to refresh the identity due to backend changes. On the `IIdentityContext` that represents the context you find a method called `refresh()`. Calling this asks the backend for the current identity details and replaces the identity kept in memory. ```typescript import { IdentityProviderContext } from '@cratis/arc.react/identity'; export const SomeComponent = () => { return ( {({ identity }) => ( <>

{identity.name}

)}
); }; ``` ## useIdentity() hook Anywhere within your application you can then access the identity by adding using the `useIdentity()` hook: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; export const Home = () => { const identity = useIdentity(); return (

User: {identity.name}

); }; ``` The `useIdentity()` hook returns the context which holds a property called `details`. This details property is what the backend returned to the ingress middleware. By default, if not specified, the type of the details is `object`. You can change this by passing it a generic argument with the exact shape of what’s expected: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; type Identity = { firstName: string; lastName: string; }; export const Home = () => { const identity = useIdentity(); if (identity.isLoading) return

Loading…

; if (!identity.isSet || !identity.details) return

No identity details.

; return (

User: {identity.details.firstName} {identity.details.lastName}

); }; ``` The generic describes the expected payload; it does not make optional backend details present. Guard details even after loading finishes, or supply the [deliberate defaults](#default-details-value) shown below. ## Has the identity arrived yet? The identity is fetched from the backend, which means there is a moment - short, but real - where your application is rendering and nobody knows who the user is yet. `isSet` cannot tell you about that moment: it reads `false` both *before* the first request has answered and *after* it answered that nobody is signed in. That is what `isLoading` is for. It is on the object `useIdentity()` returns and it separates the two: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; export const Greeting = () => { const identity = useIdentity(); if (identity.isLoading) return ; if (!identity.isSet) return ; return

Welcome back, {identity.name}

; }; ``` `isLoading` covers every resolution, not only the first one. Calling `refresh()` - which is what you do after a sign-in or after the backend grants a role - puts it back up until the round-trip answers, so anything that gates on who the user is keeps waiting instead of briefly deciding on the old identity. Note that `isLoading` lives on `IIdentityContext`, not on the framework-agnostic `IIdentity` in the core package. It is a React lifecycle concern and it stays on the React side. ## Role checking The identity context includes information about the roles assigned to the user. You can check if a user is in a specific role using the `isInRole()` method, or read the roles array directly: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; export const UserProfile = () => { const identity = useIdentity(); return (

User: {identity.name}

Roles: {identity.roles.join(', ')}

{identity.isInRole('Admin') && }
); }; ``` That is the right shape for decorating a screen. It is the wrong shape for guarding one: ```typescript // Don't do this - it decides on an identity that may not have arrived. if (!identity.isInRole('Admin')) { return
Access denied. Admin role required.
; } ``` Before the identity request answers, an administrator holds no roles yet - so this renders “Access denied,” then swaps to the panel a moment later. Every signed-in user sees the rejection flash by on every load. There are three outcomes here, not two, and a guard has to say something about all of them. ## Guarding a screen with RequireRole `RequireRole` is that guard. It renders one of three slots, and which one is never in doubt: ```typescript import { RequireRole } from '@cratis/arc.react/identity'; export const Admin = () => ( } forbidden={}> ); ``` | Prop | Type | What it does | | --- | --- | --- | | `roles` | `string[]` | Roles that grant access - the identity needs any one of them | | `allow` | `(details, identity) => boolean` | Predicate deciding access from the identity’s details | | `children` | `ReactNode` | Rendered when the caller is authenticated and allowed | | `whileLoading` | `ReactNode` | Rendered while the identity is still being resolved. Defaults to nothing | | `forbidden` | `ReactNode` | Rendered for an anonymous caller, or an authenticated one that is not allowed. Defaults to nothing | At least one of `roles` and `allow` has to be there; the type system says so, and the component denies at runtime if `undefined` gets past it anyway. Supply both and both must pass. `forbidden` is a slot rather than a `redirectTo` on purpose. Arc.React does not depend on a router, so instead of picking one for you it lets you hand it whatever your application already uses: ```typescript }> ``` ### Deciding on details Roles are what the backend put in the token. When access depends on something your own domain knows, pass a predicate over the identity’s details: ```typescript import { RequireRole } from '@cratis/arc.react/identity'; type OrganizationDetails = { department: string; }; export const Ledger = () => ( allow={details => details?.department === 'Finance'} forbidden={}> ); ``` The details come first because that is what an application’s own access rules are written against; the whole identity arrives as a second argument when a rule also needs `isInRole`. The details are typed as possibly absent, and the `?.` above is not decoration. An identity is set as soon as the backend answers, whether or not the application registered anything to fill details in - so a predicate written as though they are always there throws on exactly the deployments that have none. ### Everything that is not a yes is a no A gate that cannot reach a decision does not open. `RequireRole` renders `forbidden` for all of these, and warns on the console for the ones that are configuration mistakes: * The caller is anonymous. * No role matched, or the predicate answered `false`. * `roles` is an empty array. That is not “no rule” - it is a rule no caller can satisfy. * `roles` is not an array at all (a `null` out of JSON configuration, say). * Neither `roles` nor `allow` was supplied. A key renamed in a feature-flag object arrives as `undefined`, compiles, lints, and would otherwise open the panel to everyone signed in. If authentication alone really is the rule, say so: `allow={() => true}`. * The predicate threw. * The identity carries no details but an `allow` predicate was supplied. There is nothing to decide on, and the two natural ways to phrase a predicate disagree about absence - one throws, the other reads it as innocence and admits - so the gate answers instead of letting the phrasing decide. ### RequireRole hides UI, it does not protect data Danger `RequireRole` is a usability feature, not a security boundary. The identity it reads comes from `/.cratis/me` and is held in the page’s memory, which means anyone with the browser’s developer tools can change it and render these children at will. Use the gate to keep people out of screens that would only frustrate them. Never use it as the thing that keeps them out of the data. Every query and command behind the gate has to carry its own `[Authorize]` / `[Roles]` on the server, where the decision is made from the real credential and cannot be edited. ## Type-safe identity with complex types If your identity details contain complex types like `Guid` from `@cratis/fundamentals`, you can enable type-safe deserialization by giving `` a `detailsType` constructor. This ensures that complex types are properly instantiated with their methods and behavior, not just plain JSON objects. First, define your identity details class with an `@field` decorator on every property that should be deserialized: ```typescript import { Guid, field } from '@cratis/fundamentals'; class UserIdentityDetails { @field(Guid) userId!: Guid; @field(String) firstName!: string; @field(String) lastName!: string; } ``` Note A details type with no `@field` decorators cannot be deserialized into. `JsonSerializer.deserializeFromInstance()` only copies members declared with `@field` - an undecorated class would silently construct an empty instance and discard everything the server sent. Cratis detects this case and passes the raw payload through unchanged instead of blanking it (with a console warning), but the fix is always to decorate every property you expect to read. Then, configure `` with the details type. This is the primary form - `` forwards it to the `IdentityProvider` it mounts internally, deserialization happens once, and every `useIdentity()` call anywhere in the tree receives a real `UserIdentityDetails` instance at runtime: ```typescript import { Arc } from '@cratis/arc.react'; export const App = () => { return ( {/* ... your app content ... */} ); }; ``` If you wire `IdentityProvider` directly instead of using `` - see [Identity provider context](#identity-provider-context) - it accepts the same `detailsType` prop. The runtime instance does not change TypeScript’s view: a bare `useIdentity()` types `details` as `object`. Pass the details type as the generic argument (or the constructor) where you read it: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; export const Home = () => { const identity = useIdentity(); // identity.details.userId is a proper Guid instance with all its methods return ( <>

User ID: {identity.details.userId.toString()}

User: {identity.details.firstName} {identity.details.lastName}

); }; ``` You only need to pass the constructor to `useIdentity()` itself when the provider was **not** configured with a `detailsType` - for example, a shared component that has to work whether or not the hosting application set one up: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; export const Home = () => { const identity = useIdentity(UserIdentityDetails); return (

User ID: {identity.details.userId.toString()}

User: {identity.details.firstName} {identity.details.lastName}

); }; ``` It is safe to supply both. `useIdentity(UserIdentityDetails)` recognizes when ``/`IdentityProvider` already deserialized the payload with that exact type and hands back the existing instance rather than deserializing it a second time - which would be destructive, not merely wasteful, for nested temporal values and concept types. This approach uses `JsonSerializer.deserializeFromInstance()` under the hood to recursively deserialize complex types, ensuring that types like `Guid`, `DateOnly`, and other `@field`-decorated custom types are properly instantiated rather than being plain JSON objects. ## Refreshing with hook Since the `useIdentity()` returns an instance of the `IIdentityContext`. So for refreshing with a hook, its easily accessible: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; type Identity = { firstName: string; lastName: string; }; export const Home = () => { const identity = useIdentity(); return ( <>

User: {identity.details?.firstName} {identity.details?.lastName}

); }; ``` ## Default value You can also provide a default value for the `details` property in the identity context. ### Clearing identity When a user logs out, you can clear the client-side identity state using `clearIdentity()`. This is available on the object returned by `useIdentity()`: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; export const ClearIdentityCacheButton = () => { const identity = useIdentity(); const handleLogout = () => { identity.clearIdentity(); }; return ; }; ``` Calling `clearIdentity()` does two things: 1. Forgets the identity kept in memory by `IdentityProvider`, so the next request for it asks `/.cratis/me` again. 2. Resets the identity context to its initial unset state (`isSet` becomes `false`, `details` is reset). > Clearing this cache is not logout: authentication cookies, tokens, and server sessions remain unchanged. Complete real sign-out through your authentication system first, then clear identity and [reconnect queries](/arc/frontend/react/arc/#reconnecting-queries). Reconnection uses current browser credentials and does not purge cached query data. ## Default details value The provider starts with an empty details object, `{}`, while identity is unset. A resolved identity can still carry `null` or missing details; there is no unconditional empty-object fallback. A loading check alone does not make details safe to dereference. Supply a typed default for display placeholders. It is used while identity is unset and when resolved details are absent; it does not fill individual missing fields or establish authentication. The default value can be provided as an argument to the `useIdentity()` hook: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; type Identity = { firstName: string; lastName: string; }; export const Home = () => { const identity = useIdentity({ firstName: '[N/A]', lastName: '[N/A]' }); return (

User: {identity.details.firstName} {identity.details.lastName}

); }; ``` When using the type-safe overload with a constructor, the default value is provided as the second parameter: ```typescript import { useIdentity } from '@cratis/arc.react/identity'; import { Guid, field } from '@cratis/fundamentals'; class UserIdentityDetails { @field(Guid) userId!: Guid; @field(String) firstName!: string; @field(String) lastName!: string; } export const Home = () => { const defaultDetails: UserIdentityDetails = { userId: Guid.empty, firstName: '[N/A]', lastName: '[N/A]' }; const identity = useIdentity(UserIdentityDetails, defaultDetails); return (

User: {identity.details.firstName} {identity.details.lastName}

); }; ``` Note The default value is used as-is and is never run through deserialization - pass it already in its final, typed shape (as shown above, a plain object literal satisfying the class shape is enough). This matters because deserializing it would be redundant at best, and destructive at worst for nested temporal values and concept types. # Messaging > Get the nearest messenger with useMessenger() and publish or subscribe to typed messages between React components. `` sets up a root messenger that is available through React context. Use `useMessenger()` to get the nearest messenger: ```tsx import { useMessenger } from '@cratis/arc.react/messaging'; export const Details = () => { const messenger = useMessenger(); // use messenger.subscribe(...) or messenger.publish(...) return null; }; ``` ## Subscribing — `useOnMessage` `useOnMessage` subscribes to messages of a given type from the nearest messenger for the lifetime of the component. The subscription is cleaned up automatically on unmount, and the callback does not need to be wrapped in `useCallback` — the latest reference is always invoked. ```tsx import { useOnMessage } from '@cratis/arc.react/messaging'; class UserSelected { constructor(readonly userId: string) { } } export const UserDetails = () => { useOnMessage(UserSelected, message => { console.log('Selected user:', message.userId); }); return null; }; ``` ### Filtering An optional filter narrows on message content without re-subscribing. The filter receives the actual typed message and tells the hook whether the consumer is interested: ```tsx useOnMessage( UserSelected, message => console.log('Selected tracked user:', message.userId), message => message.userId === '42' ); ``` ## Publishing — `useSendMessage` `useSendMessage` returns a stable function that publishes to the nearest messenger. Use it when a component needs to send messages without holding a reference to the messenger itself: ```tsx import { useSendMessage } from '@cratis/arc.react/messaging'; class UserSelected { constructor(readonly userId: string) { } } export const UserList = () => { const send = useSendMessage(); return ( ); }; ``` ## Scoped messenger hierarchy Use `` to create a nested messenger: ```tsx import { MessengerScope } from '@cratis/arc.react/messaging'; export const Page = () => ( ); ``` `useMessenger()`, `useOnMessage`, and `useSendMessage` all resolve the nearest scope first, then fall back to the Arc root messenger. By default: * Child publishes do **not** bubble to parent. * Parent publishes trickle down to children recursively. You can override this on a scope with: * `bubbleToParent` * `trickleDownToChildren` # MVVM with React > Move substantial React screen behavior into testable, reusable view model classes. For most screens, the [React hooks](/arc/frontend/react/) keep state in the component and that’s exactly right. But as a screen’s logic grows — orchestrating several commands, deriving display state, reacting to navigation — that logic starts to crowd the markup. The **Model-View-ViewModel** approach moves it into a dedicated view model class: the component renders, the view model decides. The result is logic you can test and reuse independently of the JSX. Reach for MVVM when a screen has substantial behavior; stay with plain hooks when it doesn’t. | Topic | What it covers | | --- | --- | | [Using a view model](/arc/frontend/react/mvvm/using-view-model/) | Bind a component to a view model and drive it from there. | | [MVVM context](/arc/frontend/react/mvvm/mvvm-context/) | How the MVVM context is established and resolved. | | [TSyringe](/arc/frontend/react/mvvm/tsyringe/) | Dependency injection for view models with TSyringe. | | [Browser](/arc/frontend/react/mvvm/browser/) | Working with browser APIs and navigation from a view model. | | [Dialogs](/arc/frontend/react/mvvm/dialogs/) | Driving dialogs from a view model. | | [Identity](/arc/frontend/react/mvvm/identity/) | Accessing identity and user context in MVVM. | | [Messaging](/arc/frontend/react/mvvm/messaging/) | Messaging and communication patterns in MVVM. | New to the frontend? Start with the [React hooks](/arc/frontend/react/) first — MVVM is the next step up, not the starting point. # Browser > Use the INavigation and ILocalStorage abstractions in view models instead of browser globals, and know their current limits. When working with view models in the MVVM pattern, you typically want to avoid direct dependencies on browser globals like `window`, `document`, or `localStorage`. This makes your code more testable and maintainable. Cratis Arc provides abstracted interfaces for common browser functionality that can be injected into your view models. ## Available Interfaces ### INavigation The `INavigation` interface provides a way to observe URL changes in your application without directly accessing the browser’s navigation APIs. ```typescript export type UrlChangedCallback = (url: string, previousUrl: string) => void; export abstract class INavigation { abstract onUrlChanged(callback: UrlChangedCallback): void; } ``` #### Usage in a View Model ```typescript import { injectable, inject } from 'tsyringe'; import { INavigation } from '@cratis/arc.react.mvvm/browser'; @injectable() export class MyViewModel { constructor(@inject(INavigation) private readonly _navigation: INavigation) { this._navigation.onUrlChanged((url, previousUrl) => { console.log(`Navigated from ${previousUrl} to ${url}`); // Handle navigation change }); } } ``` The `Navigation` implementation watches DOM mutations with a `MutationObserver` and compares `location.href` with the URL captured when the service was created. That lets it notice client-side routing changes, such as React Router navigations, when they re-render the page. Current limits of INavigation * `previousUrl` is the URL at service creation, not the URL before the latest navigation. * Because the comparison is against that initial URL, once the app has navigated away, every later DOM mutation calls your callback again with the same `url`. Make callbacks idempotent; the visited-URL example below records duplicates. * A URL change that causes no DOM mutation is not reported. * `onUrlChanged` returns no unsubscribe function and `Navigation` is a singleton, so a subscribed view model is retained for the life of the app. When every navigation must be observed exactly once, use your router’s location API instead. ### ILocalStorage The `ILocalStorage` interface provides access to browser local storage following the standard `Storage` interface: ```typescript export abstract class ILocalStorage implements Storage { abstract length: number; abstract clear(): void; abstract getItem(key: string): string | null; abstract key(index: number): string | null; abstract removeItem(key: string): void; abstract setItem(key: string, value: string): void; } ``` #### Usage in a View Model ```typescript import { injectable, inject } from 'tsyringe'; import { ILocalStorage } from '@cratis/arc.react.mvvm/browser'; @injectable() export class PreferencesViewModel { constructor(@inject(ILocalStorage) private readonly _localStorage: ILocalStorage) { } savePreference(key: string, value: string): void { this._localStorage.setItem(key, value); } loadPreference(key: string): string | null { return this._localStorage.getItem(key); } clearAllPreferences(): void { this._localStorage.clear(); } } ``` ## Dependency Injection Setup These browser interfaces are automatically registered when you initialize the MVVM bindings. The registration happens in the `Bindings.initialize()` method: * `INavigation` is registered as a singleton with the `Navigation` implementation * `ILocalStorage` is registered as an instance pointing to the browser’s native `localStorage` You don’t need to manually register these - they’re available as soon as you set up your MVVM context. ## Benefits Using these abstracted interfaces provides several advantages: 1. **Testability**: You can easily mock these interfaces in your unit tests 2. **Decoupling**: Your view models don’t directly depend on browser globals 3. **Consistency**: All browser interactions go through well-defined interfaces 4. **Type Safety**: Full TypeScript support with proper typing ## Example: Complete View Model Here’s a complete example showing both interfaces in use: ```typescript import { injectable, inject } from 'tsyringe'; import { INavigation, ILocalStorage } from '@cratis/arc.react.mvvm/browser'; import { makeObservable, observable } from 'mobx'; @injectable() export class NavigationHistoryViewModel { @observable currentUrl: string = ''; @observable visitedUrls: string[] = []; constructor( @inject(INavigation) private readonly _navigation: INavigation, @inject(ILocalStorage) private readonly _localStorage: ILocalStorage ) { makeObservable(this); // Load previously visited URLs from storage const stored = this._localStorage.getItem('visitedUrls'); if (stored) { this.visitedUrls = JSON.parse(stored); } // Track URL changes this._navigation.onUrlChanged((url, previousUrl) => { this.currentUrl = url; this.visitedUrls.push(url); this._localStorage.setItem('visitedUrls', JSON.stringify(this.visitedUrls)); }); } clearHistory(): void { this.visitedUrls = []; this._localStorage.removeItem('visitedUrls'); } } ``` # Dialogs in MVVM > Request a dialog from a view model and handle its result/response tuple through a mounted view wrapper. A view model can ask the user a question without rendering a modal. `IDialogs` sends a typed request; the view registers and renders the matching dialog. Configure [TSyringe and bindings](/arc/frontend/react/mvvm/tsyringe/) and the router/MVVM context before using these examples. ## Custom dialogs Use a **class** for the request because registration needs a runtime constructor; a TypeScript interface alone cannot identify a message type. The following files form a contract checkpoint beneath your existing Arc/MVVM host. The native nonmodal dialog is deliberately minimal; use an accessible modal renderer in a production application. `NameDialog.tsx`: ```tsx import { DialogResult, useDialogContext } from '@cratis/arc.react/dialogs'; export class NameRequest { constructor(readonly name: string) {} } export function NameDialog({ name }: NameRequest) { const { closeDialog } = useDialogContext(); return (

{name}

); } ``` `FeatureViewModel.ts`: ```ts import { injectable } from 'tsyringe'; import { DialogResult } from '@cratis/arc.react/dialogs'; import { IDialogs } from '@cratis/arc.react.mvvm/dialogs'; import { NameRequest } from './NameDialog'; @injectable() export class FeatureViewModel { selectedName = ''; constructor(private readonly _dialogs: IDialogs) {} async chooseName() { const [result, response] = await this._dialogs.show(new NameRequest('Ada')); if (result === DialogResult.Ok && response !== undefined) { this.selectedName = response; } } } ``` `Feature.tsx`: ```tsx import { withViewModel } from '@cratis/arc.react.mvvm'; import { useDialog } from '@cratis/arc.react.mvvm/dialogs'; import { FeatureViewModel } from './FeatureViewModel'; import { NameDialog, NameRequest } from './NameDialog'; export const Feature = withViewModel(FeatureViewModel, ({ viewModel }) => { const [NameWrapper] = useDialog(NameRequest, NameDialog); return ( <>

{viewModel.selectedName}

); }); ``` The MVVM `useDialog(RequestType, Component)` returns `[Wrapper, showDialog]`, **not a context object**. This differs from the base React hook’s three-element tuple and generic ordering. Register the wrapper in the view before the view model sends requests. `IDialogs.show()` resolves to `[DialogResult, response?]`. Do not compare the entire tuple with a string. `Cancelled` is the cancellation enum member; `None`, `Ok`, `Yes`, and `No` are also available. ## Confirmation dialogs `IDialogs.showConfirmation(title, message, DialogButtons)` resolves directly to a `DialogResult`. This special convenience method does not return the custom-dialog tuple. Only perform a confirmed operation after explicitly matching the expected affirmative result. Configure `DialogComponents confirmation={YourConfirmationDialog}` as described in [React dialogs](/arc/frontend/react/dialogs/#defining-the-confirmation-dialog). Handle all requested button sets, including the Cancel button for `YesNoCancel`. ## Busy indicator dialogs `IDialogs.showBusyIndicator(title, message)` returns an object with `close()`. Configure the busy renderer on `DialogComponents`, and call `close()` in a `finally` block around the application operation. Rendering a busy dialog is not a transactional guarantee or cancellation mechanism. ## Dialog with a view model A dialog can itself use `withViewModel(DialogViewModel, renderer)`. Inject `DialogContextContent` into its view model; both type arguments are required. Read `.request` and call `.closeDialog(DialogResult.Ok, response)` or `.closeDialog(DialogResult.Cancelled)` there. `withViewModel` still needs **both** the constructor and renderer, just like the complete Feature example. Use the [base React dialog contract](/arc/frontend/react/dialogs/) for visibility and request/response semantics, and [view-model lifecycle](/arc/frontend/react/mvvm/using-view-model/) for cleanup. # Identity > Access the current identity from an MVVM view model through the IIdentityProvider registered in the container. The MVVM implementation of identity is built on top of what you find the [core](/arc/frontend/core/identity/). To access identity in an MVVM solution with a view model, the `IdentityProvider` is hooked up to the [container](/arc/frontend/react/mvvm/tsyringe/) through its interface `IIdentityProvider`. > Note: This depends on the [MVVM Context](/arc/frontend/react/mvvm/mvvm-context/) being used. ```typescript import { injectable } from 'tsyringe'; import { IIdentityProvider } from '@cratis/arc/identity'; type IdentityDetails = { department: string, age: number }; @injectable() export class MyViewModel { constructor(private readonly _identityProvider: IIdentityProvider) { } async sayHello() { const identity = await this._identityProvider.getCurrent(); console.log(`Hello '${identity.name}' from ´${identity.details.department}`); } } ``` The code takes a dependency to the abstract class called `IIdentityProvider` representing the interface for the identity provider. With this the code simply calls the `getCurrent()` method to get the identity. Note The `` above is a **type parameter** - it only shapes what TypeScript expects `identity.details` to look like at compile time and has no effect at runtime. `identity.details` is still the raw JSON object the identity provider returned. If your details contain complex types like `Guid` that need to be properly instantiated - not left as plain JSON - pass a class **constructor** as a runtime argument to `getCurrent()` instead of (or in addition to) a type parameter, with an `@field` decorator on every property you want deserialized: ```typescript import { injectable } from 'tsyringe'; import { IIdentityProvider } from '@cratis/arc/identity'; import { Guid, field } from '@cratis/fundamentals'; class IdentityDetails { @field(Guid) userId!: Guid; } @injectable() export class MyViewModel { constructor(private readonly _identityProvider: IIdentityProvider) { } async sayHello() { // The constructor argument is what deserializes - not just a type parameter. const identity = await this._identityProvider.getCurrent(IdentityDetails); console.log(identity.details.userId.toString()); } } ``` A class with no `@field` decorators cannot be deserialized into - the raw payload is passed through unchanged instead of being silently blanked. # Messaging > Let separate MVVM features communicate through the messenger, and scope messaging to part of a page. At times you want to allow multiple features on a page to know about changes that happens in one component. A common scenario is a list + detail scenario where you typically have a list and when you click an item you get the details of it somewhere else on the page. With React you would typically create a context and wrap the components needing the same information in a React context. The problem with this is that you are having to create these different contexts, which creates a set of boiler plate code that has little value and you need to put it into the correct place in the rendering hierarchy. This creates a logical coupling in your application and is error prone when changing structure. With the MVVM model this doesn’t really work either, as the contexts aren’t available to the view model. With publish/subscribe, components can use their view models to publish messages that can be subscribed to by other parts of your system that lives on the page. This creates for a more decoupled approach and making your code and structure easier to change. ## IMessenger In Cratis Arc the system that deals with this is called `IMessenger`. It has a method called `publish()` for publishing messages, and one for subscribing called `subscribe()`. Both of these methods work on types and require a runtime type like a `class` to be the message type. The implementation is provided by Arc core (`@cratis/arc/messaging`) and wired into MVVM bindings. Below is an example of the view models for two components, one listing items and the other holding details. These illustrative files assume an application-owned `User` model in `./User`. Define the shared message once in `UserSelected.ts` and import that same class in publisher and subscriber: ```ts import { User } from './User'; export class UserSelected { constructor(readonly user: User) {} } ``` Then for the list component we would typically have something like the following: ```ts import { IMessenger } from '@cratis/arc.react.mvvm/messaging'; import { injectable } from 'tsyringe'; import { UserSelected } from './UserSelected'; import { User } from './User'; @injectable() export class UsersListViewModel { // Take the IMessenger as a dependency constructor(private readonly _messenger: IMessenger) {} selectUser(user: User) { // Publish the message saying the user was selected this._messenger.publish(new UserSelected(user)); } } ``` Whenever a selection is done, the view would call the `selectUser()` method, which would then publish the message. For the details component, the view model would then need to subscribe to this: ```ts import { IMessenger } from '@cratis/arc.react.mvvm/messaging'; import { injectable } from 'tsyringe'; import { Subscription } from 'rxjs'; import { UserSelected } from './UserSelected'; import { User } from './User'; @injectable() export class UserDetailsViewModel { user?: User; private readonly _subscription: Subscription; constructor(messenger: IMessenger) { this._subscription = messenger.subscribe(UserSelected, (message) => { this.user = message.user; }); } detached() { this._subscription.unsubscribe(); } } ``` With this, the details view model will subscribe to the `UserSelected` message and set the state of the view model accordingly. The subscriber receives the **published message instance**, not its nested `user` payload. Retain the subscription and unsubscribe in `detached()` when the view model is disposed. With `` set, `detached()` is not guaranteed to run on unmount; see [view model lifecycle](/arc/frontend/react/mvvm/using-view-model/#view-model-lifecycle). ## Scoped messaging When using Arc React, the root `` provides a root messenger. You can add nested scopes with `MessengerScope` from `@cratis/arc.react/messaging`. View models resolved with `withViewModel()` receive the nearest scoped messenger automatically, and fall back to the root messenger. By default: * Messages do not bubble to parent scopes. * Messages from parent scopes trickle down to child scopes recursively. Override behavior through: * `bubbleToParent` * `trickleDownToChildren` # MVVM Context > Set up the component that configures MobX and Tsyringe bindings, and place the router that view models need. The MVVM solution in Cratis Arc is based on top of [mobx](https://mobx.js.org). In addition to being based on top of it, everything internal is expecting a certain behavior that needs to be configured for mobx for everything to work. This is were the `MVVM` context comes into play. The Arc exposes a component that configures mobx and also ensures the necessary bindings for [tsyringe](/arc/frontend/react/mvvm/tsyringe/) are configured. Include it in your application setup. Components created with `withViewModel()` also read route and query parameters through React Router’s `useParams` and `useSearchParams`, so they must render inside a router. `react-router-dom` 7 is a peer dependency of `@cratis/arc.react.mvvm`, and `` does not provide a router itself: ```tsx import { BrowserRouter } from 'react-router-dom'; import { MVVM } from '@cratis/arc.react.mvvm'; export const App = () => { return ( {/* Your application, including every withViewModel() component */} ); }; ``` # Tsyringe > Enable the TypeScript decorator metadata that Tsyringe needs for Arc MVVM, including the Vite metadata plugin. Cratis Arc relies on [Microsoft Tsyringe](https://github.com/microsoft/tsyringe) a lightweight IoC container for TypeScript. In order to use Tsyringe, your application needs to produce type metadata that it can use to resolve dependencies. Tsyringe uses decorators. Enable the following options in your TypeScript configuration: In your `tsconfig.json` make sure to enable the following compiler options: ```json { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } } ``` In addition to this, Tsyringe needs the `Reflect` metadata API to be available at runtime. Cratis Arc ships a lightweight polyfill for this (provided by `@cratis/fundamentals`) and loads it automatically when you import `@cratis/arc`, so there is nothing extra to install or set up. ```tsx import { App } from './App'; ReactDOM.createRoot(document.getElementById('root')!).render( , ); ``` This enables the necessary reflection metadata for Tsyringe to work properly. ## Vite If you’re using Vite you will find that things are not working as expected. This is because Vite during development with the dev server will not provide the necessary metadata. Cratis Arc offers a vite plugin for emitting the necessary metadata. Add a dev package reference to `@cratis/arc.vite`. Then in your `vite.config` you can add it to plugins: vite.config.ts ```typescript import { defineConfig } from 'vite'; import { EmitMetadataPlugin } from '@cratis/arc.vite'; export default defineConfig({ plugins: [ EmitMetadataPlugin() ] }); ``` ## Bindings In the Rect MVVM package you’ll find services that will help you as a developer work with MVVM, these are typically services you would take a dependency to in your view models. Within the package sits a type called `Bindings`, you can call this to get everything initialized: > Note: If you’re using the [MVVM context](/arc/frontend/react/mvvm/mvvm-context/) all bindings are automatically configured for the MVVM scenario and you are good to go and you can skip this step. ```ts import { Bindings } from '@cratis/arc.react.mvvm'; Bindings.initialize(); ``` # Using a View Model > Give a React component a view model with withViewModel(): dependencies, props, route and query parameters, and lifecycle. Every React functional component can have a view model. This is accomplished using the `withViewModel()` method. ```tsx import { withViewModel } from '@cratis/arc.react.mvvm'; export const Counter = withViewModel(CounterViewModel, ({viewModel}) => { return ( <> Counter is : {viewModel.counter} ) }); ``` The component uses the `withViewModel` and passes the type `CounterViewModel` to be created for the view. As arguments you can then have the `viewModel`, this instance survives re-renders and can be stateful. ```ts export class CounterViewModel { counter = 0; increaseCounter() { this.counter++; } } ``` The `viewModel` is automatically observable, which means that all properties on it will notify the view if there are any changes to them. This means that the `increaseCounter()` method can just go ahead and increase the counter and the view will automatically re-render. ## Observer boundaries `withViewModel()` observes the render of the component it wraps — and only that render. The view model is built on [mobx](https://mobx.js.org) (see [MVVM context](/arc/frontend/react/mvvm/mvvm-context/)), so MobX tracks which observable view model properties a render reads and re-renders that component when they change. This has an important consequence: a **child** component that reads observable view model state *directly* sits outside the parent’s observer boundary. MobX never tracks that read, so the child does not re-render when the state changes — even though the value on screen is stale. ```tsx import { withViewModel } from '@cratis/arc.react.mvvm'; // The child reads `viewModel.counter` directly — but it has no observer boundary of its own, // so it will NOT re-render when the counter changes. const CounterValue = ({ viewModel }: { viewModel: CounterViewModel }) => ( Counter is : {viewModel.counter} ); export const Counter = withViewModel(CounterViewModel, ({ viewModel }) => { return ( <> ); }); ``` ### Prefer passing plain props down The cleanest fix is to keep observable reads inside the wrapped component and pass plain values down to presentational children. The parent is already observed, so reading `viewModel.counter` there is tracked, and the child re-renders because its `value` prop changed like any other React prop: ```tsx import { withViewModel } from '@cratis/arc.react.mvvm'; // Presentational child — receives a plain value, knows nothing about the view model. const CounterValue = ({ value }: { value: number }) => ( Counter is : {value} ); export const Counter = withViewModel(CounterViewModel, ({ viewModel }) => { return ( <> ); }); ``` This keeps reactivity entirely within the wrapped component and leaves children as simple, predictable functions of their props. ### Wrap a leaf that must read view model state directly When a child genuinely needs to read observable view model state directly — for example a list that filters a large collection the view model owns — give just that leaf its own observer boundary with `observer()`. Import it from `@cratis/arc.react.mvvm`: ```tsx import { observer, withViewModel } from '@cratis/arc.react.mvvm'; // The leaf reads observable state directly and is wrapped in `observer()`, so it has its // own boundary and re-renders when `filteredPartners` changes. const PartnerResults = observer(({ viewModel }: { viewModel: PartnerSearchViewModel }) => (
    {viewModel.filteredPartners.map(partner =>
  • {partner.name}
  • )}
)); export const PartnerSearch = withViewModel(PartnerSearchViewModel, ({ viewModel }) => { return ( <> viewModel.setPartnerSearch(event.target.value)} /> ); }); ``` > Note: Always import `observer` from `@cratis/arc.react.mvvm`, never directly from `mobx-react` or `mobx-react-lite`. Going through Cratis Arc keeps the MobX binding an internal detail that can evolve without breaking your code, and the [Cratis Arc ESLint plugin](https://github.com/Cratis/Arc/blob/main/Source/JavaScript/Arc.ESLint/README.md) flags direct imports for you. Reach for `observer()` only on the specific leaf that reads observable state directly. Do not blanket-wrap every component — that adds render-tracking overhead everywhere and obscures which components actually depend on view model state. Prefer the plain-props pattern above, and drop down to a leaf `observer()` only when a child must read view model observables itself. ## Props Components can have props associated with them. The `withViewModel` supports specifying props type and ability to take the props in as a parameter on the render function: ```tsx import { withViewModel } from '@cratis/arc.react.mvvm'; export interface CounterProps { initialValue: number; } export const Counter = withViewModel(CounterViewModel, ({viewModel, props}) => { return ( <> Counter is : {viewModel.counter} ) }); ``` The `withViewModel` has 2 generic arguments that can be passed: * ViewModel type * Props type When you don’t have a props type, you don’t need to specify the `ViewModel` type arguments, as TypeScript will automatically infer its type from the first argument that specifies the type to use. > Note: The reason we provide the type of view model to use as a parameter to the `withViewModel` and not just rely on the `ViewModel` type argument, is that we need a proper token / type to be used to be able to create an instance of it. Generic information is only available at compile-time in TypeScript and when transpiled to JavaScript this information is gone. ### Injecting Props as dependency If you’re component only gets loaded once and the props typically don’t change without the component being unloaded and then loaded again, you can simply inject it as part of the constructor. #### Using `@props` decorator (recommended) The `@props` decorator wraps tsyringe’s `@inject` and also captures the parameter type: ```ts import { props } from '@cratis/arc.react.mvvm'; import { injectable } from 'tsyringe'; @injectable() export class MyViewModel { constructor(@props componentProps: Props) { } } ``` #### Using `@inject` manually You can also use tsyringe’s `@inject` directly with `WellKnownBindings.props`: ```ts import { WellKnownBindings } from '@cratis/arc.react.mvvm'; import { inject, injectable } from 'tsyringe'; @injectable() export class MyViewModel { constructor(@inject(WellKnownBindings.props) props: Props) { } } ``` ### Handling Props For the scenario were the props are changing by a consumer of your component, you need to implement the `IHandleProps<>`. It is a generic interface, but the generic argument is optional and is defaulted to `object` if not specified. ```ts import { IHandleProps } from '@cratis/arc.react.mvvm'; export class MyViewModel implements IHandleProps { handleProps(props: Props): void { // Do things based on props } } ``` > Note: The `handleProps` method will be called both on initial load and for any subsequent changes. ## Params Params defined as part of routes using `react-router` can easily be accessed by a view model in a couple of ways. ### Injecting Params as dependency If you’re component only gets loaded once and the parameters can’t change without the component being unloaded and then loaded again, you can simply inject it as part of the constructor. #### Using `@params` decorator (recommended) The `@params` decorator wraps tsyringe’s `@inject` and also captures the parameter type. When a typed class is provided, `withViewModel` will use `JsonSerializer` to deserialize the string-based URL parameters into the correct types. Properties on the params class must be annotated with `@field` from `@cratis/fundamentals` so the serializer knows how to convert each value — for example a `number` property will be converted from the URL string `"42"` to the integer `42`. Without `@field`, the property values remain as strings: ```ts import { field } from '@cratis/fundamentals'; import { params } from '@cratis/arc.react.mvvm'; import { injectable } from 'tsyringe'; class RouteParams { @field id!: string; @field count!: number; } @injectable() export class MyViewModel { constructor(@params routeParams: RouteParams) { // routeParams.count is a proper number, not the URL string "42" } } ``` #### Using `@inject` manually You can also use tsyringe’s `@inject` directly with `WellKnownBindings.params`. In this case the parameters are injected as raw strings as they appear in the URL: ```ts import { WellKnownBindings } from '@cratis/arc.react.mvvm'; import { inject, injectable } from 'tsyringe'; @injectable() export class MyViewModel { constructor(@inject(WellKnownBindings.params) params: Params) { } } ``` The downside of this approach is that if a param changes and the component is not unloaded, you won’t get the change. ### Handling Params For the scenario were the params are changing while the component is not unloaded, implementing the `IHandleParams<>` interface is a better option. It is a generic interface, but the generic argument is optional and is defaulted to `object` if not specified. ```ts import { IHandleParams } from '@cratis/arc.react.mvvm'; export class MyViewModel implements IHandleParams { handleParams(params: Params): void { // Do things based on params } } ``` > Note: The `handleParams` method will be called both on initial load and for any subsequent changes. ## QueryParams QueryParams defined as part of routes using `react-router` can easily be accessed by a view model in a couple of ways. ### Injecting Query Params as dependency If you’re component only gets loaded once and the query parameters can’t change without the component being unloaded and then loaded again, you can simply inject it as part of the constructor. #### Using `@queryParams` decorator (recommended) The `@queryParams` decorator wraps tsyringe’s `@inject` and also captures the parameter type. When a typed class is provided, `withViewModel` will use `JsonSerializer` to deserialize the string-based URL query parameters into the correct types. Properties on the query params class must be annotated with `@field` from `@cratis/fundamentals` so the serializer knows how to convert each value — for example a `number` property will be converted from the URL string `"1"` to the integer `1`. Without `@field`, the property values remain as strings: ```ts import { field } from '@cratis/fundamentals'; import { queryParams } from '@cratis/arc.react.mvvm'; import { injectable } from 'tsyringe'; class SearchParams { @field filter!: string; @field page!: number; } @injectable() export class MyViewModel { constructor(@queryParams searchParams: SearchParams) { // searchParams.page is a proper number, not the URL string "1" } } ``` #### Using `@inject` manually You can also use tsyringe’s `@inject` directly with `WellKnownBindings.queryParams`. In this case the parameters are injected as raw strings as they appear in the URL: ```ts import { WellKnownBindings } from '@cratis/arc.react.mvvm'; import { inject, injectable } from 'tsyringe'; @injectable() export class MyViewModel { constructor(@inject(WellKnownBindings.queryParams) queryParams: Params) { } } ``` The downside of this approach is that if a query param changes and the component is not unloaded, you won’t get the change. ### Handling Query Params For the scenario were the query params are changing while the component is not unloaded, implementing the `IHandleQueryParams<>` interface is a better option. It is a generic interface, but the generic argument is optional and is defaulted to `object` if not specified. ```ts import { IHandleQueryParams } from '@cratis/arc.react.mvvm'; type SearchParams = { term: string; }; export class MyViewModel implements IHandleQueryParams { handleQueryParams(queryParams: SearchParams): void { // Do things based on query params } } ``` > Note: The `handleQueryParams` method will be called both on initial load and for any subsequent changes. ## View Model lifecycle ### Detaches You can get notified when a view model is detached from its view, typically as a consequence of the view being removed from the DOM. This is achieved by implementing the `IViewModelDetached` interface on your view model: ```ts import { IViewModelDetached } from '@cratis/arc.react.mvvm'; export class MyViewModel implements IViewModelDetached { detached() { // Clean up... } } ``` Not called in development mode `withViewModel()` reliably disposes the view model, and calls `detached()`, on unmount only when `` runs with `development` unset or `false`. With ``, an ordinary unmount skips disposal, although React StrictMode’s repeated effects can still trigger it. Treat `detached()` as not guaranteed during development, and do not rely on it to release resources there. # Consume generated proxies > Use backend-generated command and query contracts from React without a hand-written API client. A backend change should produce a compiler error where your frontend needs updating, not a surprise in production. Arc generates TypeScript command, query, and model types from discovered endpoints — for the shared frontend runtime. Run the generator for your backend, then update your consumers; never edit generated files. You get completion for command fields and query arguments, typed responses, and compiler-guided repairs after a backend rename. The generated modules also give you concrete contracts to navigate instead of hand-maintained request shapes. ## Set up the frontend Current generated command and query modules import both `@cratis/arc` and `@cratis/arc.react`, even when you call them imperatively. Install their dependencies in your React app: ```bash npm install @cratis/fundamentals @cratis/arc @cratis/arc.react react react-dom ``` Mount [Arc](/arc/frontend/react/arc/) above hook consumers. For build configuration, namespace mapping, source-file grouping, and output ownership, use the C# [backend proxy-generation reference](/arc/backend/csharp/proxy-generation/), the JVM [TypeScript proxies guide](/arc/backend/kotlin/guides/typescript-proxies/), or the TypeScript source preview’s [generator setup](/arc/backend/typescript/proxy-generation/getting-started/). ## Commands use your application services Arc is a standalone CQRS framework. A command can call an ordinary service and return a response; it does not need events or an event log. This backend fragment assumes your application implements and registers the account service. That interface is application-owned, not an Arc API: * C# ```csharp using Cratis.Arc.Commands.ModelBound; using Cratis.Concepts; public record AccountName(string Value) : ConceptAs(Value) { public static readonly AccountName NotSet = new(string.Empty); } public record AccountBalance(decimal Value) : ConceptAs(Value) { public static readonly AccountBalance NotSet = new(0m); } public interface IAccountService { Task Open(AccountName name, AccountBalance initialBalance); } [Command] public record OpenDebitAccount(AccountName Name, AccountBalance InitialBalance) { public Task Handle(IAccountService accounts) => accounts.Open(Name, InitialBalance); } ``` * Java ```java import io.cratis.fundamentals.concepts.ConceptAs; import java.math.BigDecimal; import java.util.UUID; public record AccountName(String value) implements ConceptAs { } public record AccountBalance(BigDecimal value) implements ConceptAs { } public interface AccountService { UUID open(AccountName name, AccountBalance initialBalance); } @Command public record OpenDebitAccount(AccountName name, AccountBalance initialBalance) { public UUID handle(@FromServices AccountService accounts) { return accounts.open(name, initialBalance); } } ``` * Kotlin ```kotlin import io.cratis.fundamentals.concepts.ConceptAs import java.math.BigDecimal import java.util.UUID data class AccountName(private val rawValue: String) : ConceptAs { override fun value(): String = rawValue } data class AccountBalance(private val rawValue: BigDecimal) : ConceptAs { override fun value(): BigDecimal = rawValue } interface AccountService { suspend fun open(name: AccountName, initialBalance: AccountBalance): UUID } @Command data class OpenDebitAccount(val name: AccountName, val initialBalance: AccountBalance) { suspend fun handle(@FromServices accounts: AccountService): UUID = accounts.open(name, initialBalance) } ``` * TypeScript (preview) ```typescript import { ConceptAs, field, Guid } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; export class AccountName extends ConceptAs { static readonly valueType = String; } export class AccountBalance extends ConceptAs { static readonly valueType = Number; } export abstract class AccountService { abstract open(name: AccountName, initialBalance: AccountBalance): Promise; } @command() export class OpenDebitAccount { @field(AccountName) name!: AccountName; @field(AccountBalance) initialBalance!: AccountBalance; @inject(AccountService) handle(accounts: AccountService): Promise { return accounts.open(this.name, this.initialBalance); } } ``` The named concepts keep account names and balances distinct in backend signatures. Their generated client properties use the underlying primitive — here `string` and `number` — so the form does not need to construct concept objects. Keep each concept in its own file when organizing your application. After generation, use the proxy from a React component. This consumer fragment assumes its generated import path is `./generated/OpenDebitAccount`: ```tsx import { OpenDebitAccount } from './generated/OpenDebitAccount'; export function OpenAccountButton() { const [command] = OpenDebitAccount.use({ name: 'Primary account', initialBalance: 500 }); async function open() { const result = await command.execute(); if (result.isSuccess) { console.log(String(result.response)); } } return ; } ``` An ordinary `Guid`/`UUID` response remains a supported response and maps to Fundamentals `Guid`. The [Chronicle integration](/arc/backend/csharp/chronicle/commands/) adds optional event-sourcing behavior; it does not redefine generic Arc responses. The command hook returns `[command, setValues, clearValues]`. Set required content explicitly. The setter edits properties; it does not reset the change-tracking baseline. See [data binding](/arc/frontend/react/commands/data-binding/). ## Queries follow the backend result shape Both [controller-based](/arc/backend/csharp/queries/controller-based/) and [model-bound](/arc/backend/csharp/queries/model-bound/) C# endpoints generate proxies; the JVM generates from model-bound `@ReadModel` query methods only — it has no controller-based discovery. TypeScript’s source generator reads `@readModel()` classes with static `@query()` methods; see its [proxy generation overview](/arc/backend/typescript/proxy-generation/) for supported output. Parameterized queries emit a `NameParameters` interface. Query hooks return tuples, not model objects; read the first tuple element’s `.data`. * Ordinary `.use()` returns `[result, perform, setSorting]`. * Observable enumerable `.use()` returns `[result, setSorting]`. * Observable single-model `.use()` returns `[result]`. * Only enumerable proxies expose `.useWithPaging()`, `.useSuspenseWithPaging()`, and, for observables, `.useChangeStream()`. See [query usage](/arc/frontend/react/queries/usage/) for argument positions and the complete tuple matrix. Results expose `isPerforming`, `isReady`, `isAuthorized`, `isValid`, `hasExceptions`, and `exceptionMessages`; they do not expose an `error` or `isLoading` property. ## Live and Suspense consumers Observable proxies subscribe to an emitting backend source. MongoDB observation is one option; Chronicle is another optional integration. A database write does not become a live notification without that source wiring. Both [SSE and WebSocket](/arc/frontend/react/queries/observable-query-multiplexing/) support direct and shared-hub modes. All generated queries expose `.useSuspense()`. Suspense handles initial waiting and selected failures, not every unsuccessful result: keep validation/readiness handling in the component. Read [Suspense limitations](/arc/frontend/react/queries/suspense-queries/) before adding an error boundary or retry control. # Queries > Overview of React query capabilities in Arc: request/response reads, paging, conditional and suspense queries, and live observable streams. React queries in Arc provide strongly typed data-access patterns for request/response reads, paged views, and live observable streams. This page is an overview of capabilities. Detailed behavior is documented on the dedicated pages below. ## Capabilities | Capability | What It Covers | Learn More | | --- | --- | --- | | Arc-level configuration | Query-related `` props for transport, headers, and stream transfer mode | [Configuration](/arc/frontend/react/queries/configuration/) | | Core query usage | `use()` patterns, arguments, and `QueryResultWithState` | [Core Query Usage](/arc/frontend/react/queries/usage/) | | Paging and sorting | `useWithPaging`, page/sort callbacks, and paging metadata | [Paging](/arc/frontend/react/queries/paging/) | | Observable streams | Real-time subscriptions, transport selection, and direct mode | [Observable Queries](/arc/frontend/react/queries/observable-queries/) | | Diagnostics | Live snapshots for cache, transport, and ownership state | [Observable Query Diagnostics](/arc/frontend/react/queries/observable-query-diagnostics/) | | Suspense integration | `useSuspense()` with query boundaries and error boundaries | [Suspense Queries](/arc/frontend/react/queries/suspense-queries/) | | Conditional execution | `when(condition)` patterns for safe query activation | [Conditional Queries](/arc/frontend/react/queries/conditional-queries/) | | Change deltas | `useChangeStream()` and transfer-mode behavior | [Change Stream](/arc/frontend/react/queries/change-stream/) | | Connection behavior | Multiplexing, connection count, and hub routing | [Observable Query Multiplexing](/arc/frontend/react/queries/observable-query-multiplexing/) | | Instance lifecycle | Shared query instances and cache behavior | [Query Instance Caching](/arc/frontend/react/queries/query-instance-caching/) | | Performing state | Track in-flight queries across components and ViewModels | [Query Scope](/arc/frontend/react/queries/scope/) | ## Frontend Layering For low-level query contracts and non-React runtime behavior, see [Frontend Core Queries](/arc/frontend/core/queries/). ## Backend References * [Backend Queries Overview](/arc/backend/csharp/queries/) * [Controller-based Queries](/arc/backend/csharp/queries/controller-based/) * [Model-bound Queries](/arc/backend/csharp/queries/model-bound/) * [Query Pipeline](/arc/backend/csharp/queries/query-pipeline/) * [Backend Proxy Generation](/arc/backend/csharp/proxy-generation/) # Change stream > Consume snapshot changes from enumerable observable queries, with exact generated overloads and Full/Delta limitations. A live list consumer sometimes needs additions, replacements, and removals instead of the latest full array. `useChangeStream` exposes those changes as React state. It is a snapshot-delta view, **not an audit log or an operation-by-operation database stream**. Arc does not require MongoDB or Chronicle for it. ## Generated proxy method Only enumerable observable queries generate `useChangeStream`. Argument positions depend on whether the backend query has parameters. These are illustrative calls inside components consuming the named generated proxies: ```tsx // Parameterless query: first argument is the key accessor. const allChanges = AllBooks.useChangeStream(book => String(book.id)); // Parameterized query: first argument is the parameters object. const authorChanges = BooksForAuthor.useChangeStream( { authorId }, book => String(book.id) ); ``` For a parameterized query whose arguments are optional, `useChangeStream(undefined, getKey)` is valid. It is not the signature for a parameterless proxy. Use a stable primitive key; newly deserialized Guid objects are not stable JavaScript Map keys, so convert them to strings. ## useChangeStream hook The raw hook takes `(query, args?, getKey?, sorting?, isEnabled = true)` and returns `ChangeSet`. It reuses ordinary observable subscription/cache infrastructure. It does not add a separate transport connection for the same shared subscription. | Member | Meaning | | --- | --- | | `added` | Newly present items | | `replaced` | Current items whose stable keys match but serialized content changed | | `removed` | Previously present items no longer in the snapshot | For local comparison, `getKey` enables replacement detection. Without it, full JSON content identifies items, so edits appear as removal plus addition. Reordering alone is not an item-content change. The return value is the latest change state, not a queue of every intermediate event. Disabling the hook suppresses its automatic subscription/change processing, not all cache creation; see [conditional queries](/arc/frontend/react/queries/conditional-queries/). ## Transfer modes Processing follows this order: 1. If a result includes a server `changeSet`, use it directly, **even in local Full mode**. 2. Otherwise, the first processed snapshot or local `ObservableQueryTransferMode.Full` returns all current items as `added` and empty replacement/removal arrays. 3. Otherwise, local Delta mode compares previous and current snapshots. Configure the mode through ``, importing the enum from `@cratis/arc`, before establishing subscriptions. This global setting also travels in shared-hub subscription requests: the server sends full snapshots in Full mode, or an initial snapshot followed by delta-only collection updates in Delta mode. It is not isolated by nested providers, and it does not select SSE versus WebSocket or guarantee operation-level transport fidelity. The ordinary observable `.use()` hook reconstructs delta-only collections; [observable Suspense currently does not](/arc/frontend/react/queries/suspense-queries/#observable-collections-and-delta-only-updates). The server’s `ChangeSetComputor` also compares snapshots. Intermediate writes between emissions can disappear from the observed delta. Equivalent cached data may suppress notifications, so Full mode does not guarantee one callback per server tick. Use an actual durable event/audit source when every operation matters. ## IChangeStreamFor `IChangeStreamFor` extends `IObservableQueryFor` and is the structural contract used by the raw hook. Generated enumerable observable proxies satisfy it. No separate marker attribute or MongoDB operation feed is required. ## See also * [Backend change streams](/arc/backend/csharp/queries/change-stream/) * [Observable query multiplexing](/arc/frontend/react/queries/observable-query-multiplexing/) * [Query instance caching](/arc/frontend/react/queries/query-instance-caching/) # Conditional Queries > Delay a query until its arguments exist with the generated when() method, for one-shot and observable queries. Sometimes a component receives a query argument through a prop or local state that may not be available yet — for example, an entity ID that starts as `undefined` until the user makes a selection. Issuing the query before the value is available leads to an invalid request. At the same time, React’s rules of hooks prohibit calling hooks inside `if` statements, so you cannot simply wrap `use()` in a conditional. The `when()` pattern solves this. Every generated query class exposes a static `when(condition)` method that returns a `QueryWhen` (or `ObservableQueryWhen`) wrapper. The wrapper exposes hook methods that forward the condition as `isEnabled`. When false, it suppresses automatic requests/subscriptions; it does not suppress all instance creation or cache access. Use paging only for enumerable results with backend paging support. `QueryWhen` and `ObservableQueryWhen` are classes, not JSX components. ## Generated API The proxy generator emits a `when()` static method on every query and observable query class: ```ts // Generated for a regular query static when(condition: boolean): QueryWhen // Generated for an observable query static when(condition: boolean): ObservableQueryWhen ``` ## Basic Usage ```tsx import { AllAccounts } from './Proxies'; export const AccountDetail = ({ accountId }: { accountId: string | undefined }) => ( ); const AccountLoader = ({ accountId }: { accountId: string | undefined }) => { // Hook always runs — no conditional hook violation. // When accountId is undefined the query is disabled. const [accounts] = AllAccounts.when(!!accountId).use({ id: accountId ?? '' }); if (!accountId) return

Select an account.

; if (!accounts.isReady || accounts.isPerforming) return

Loading…

; if (!accounts.isSuccess) return

Could not load accounts.

; return
{accounts.data.map(a =>

{a.name}

)}
; }; ``` The key belongs on the component that owns the query hook. When the selection changes from one nonempty ID to another, React remounts that loader with the new arguments. Without this boundary, the current ordinary non-Suspense hook can retain the previous selection’s ready result while the next request runs; `isReady` and `isPerforming` alone do not establish which ID produced the data. The keyed loader prevents that prior selection from being displayed as the new one. A cached result for the **same** ID may still appear while it revalidates; this is not a freshness or authorization guarantee. When `condition` is `false`: * This hook does not automatically start a request or subscription. An existing shared subscription may remain active through another consumer or cache retention. * The hook returns `QueryResultWithState.empty(defaultValue)` with `isPerforming: false`. Enumerable defaults are `[]`; single-result proxies default to `{}`, which makes `hasData` true. Neither `hasData` nor an empty result proves the query ran. * For ordinary non-Suspense hooks, an explicit `perform()` can still execute: the condition is not an authorization boundary. When `condition` becomes `true` on a subsequent render, the hook connects to the server and the component re-renders with real data. ## With Paging ```tsx const [accounts] = AllAccounts.when(isReady).useWithPaging(20, args); ``` ## With Suspense ```tsx // Inside a boundary const [feed] = LiveFeed.when(!!topicId).useSuspense({ topic: topicId ?? '' }); ``` ## Observable Queries The pattern works identically for observable queries via `ObservableQueryWhen`: ```tsx import { LiveFeed } from './Proxies'; export const FilteredFeed = ({ author }: { author: string }) => { // Subscription only starts once author is non-empty const [feed] = LiveFeed.when(author.length > 0).use({ author }); return
    {feed.data.map(m =>
  • {m.text}
  • )}
; }; ``` ## How It Interacts with `QueryInstanceCache` Non-Suspense hooks create/look up and acquire an entry before checking the condition in their effects. Disabled Suspense hooks also construct a query to obtain its default value, but do not start an enabled Suspense resource. Instance allocation is therefore not prevented by `when(false)`. Observable cache listeners and ordinary query fetching have different sharing behavior; see [query instance caching](/arc/frontend/react/queries/query-instance-caching/). ## Memoization `when()` creates a lightweight wrapper object on every render. For components where the condition expression is complex or the render is performance-sensitive, you can memoize the result: ```ts const query = useMemo(() => AllAccounts.when(!!accountId), [accountId]); const [accounts] = query.use({ id: accountId ?? '' }); ``` In practice the object is small enough that memoization is rarely needed. ## `isEnabled` on Raw Hooks Behind the scenes `QueryWhen` and `ObservableQueryWhen` delegate to the raw `useQuery` / `useObservableQuery` (and their `*WithPaging` and `useSuspense*` variants) by passing `isEnabled` as the last argument. The raw hooks also accept this parameter directly if you need low-level control: ```ts import { useObservableQuery } from '@cratis/arc.react/queries'; const [result] = useObservableQuery(MyQuery, args, sorting, isEnabled); ``` The generated `when()` API is preferred for application code because it keeps intent explicit and co-located with the query class. # Configuration > Reference for the props that configure queries centrally: routing, headers, transport, hub connections, transfer mode, and caching. Configure query behavior centrally through the `` component instead of per query instance. ## Query-Related Arc Props | Prop | Type | Default | Purpose | | --- | --- | --- | --- | | `microservice` | `string` | `''` | Routes query requests to a named microservice in shared-ingress environments. | | `apiBasePath` | `string` | `''` | Prepends a base API path to query requests. | | `httpHeadersCallback` | `() => HeadersInit` | `undefined` | Adds headers to ordinary fetches and SSE control POSTs, not native stream handshakes. | | `queryTransportMethod` | `QueryTransportMethod` | `ServerSentEvents` | Selects SSE or WebSocket transport for observable query connections. | | `queryDirectMode` | `boolean` | `false` | Bypasses centralized hubs and connects observable queries directly per query URL. | | `queryConnectionCount` | `number` | `1` | Number of observable query hub connection slots. | | `observableQueryTransferMode` | `ObservableQueryTransferMode` | `Delta` | Selects shared-hub server full/delta emissions and `useChangeStream()` fallback processing. | | `queryCacheRetentionMs` | `number` | `30000` | How long to keep cached query data alive after the last subscriber unmounts. | | `eventSourceFactory` | `(url: string) => EventSource` | `undefined` | Custom factory for creating the `EventSource` instances used by SSE observable query connections. Falls back to the global `EventSource` constructor when not set. | ## Example ```tsx import { Arc } from '@cratis/arc.react'; import { ObservableQueryTransferMode } from '@cratis/arc'; import { QueryTransportMethod } from '@cratis/arc/queries'; export const App = () => ( ); ``` The example’s `MyRoutes` is your application’s route component. For bearer authentication, configure the [fetch/stream credential paths](/arc/frontend/react/arc/#http-headers-callback) separately; setting callback headers does not authenticate a native EventSource or WebSocket handshake. For ordinary query HTTP method selection (`Get`, `Query`, `Auto`), see [using the HTTP QUERY method](/arc/backend/csharp/queries/using-the-http-query-method/). It is separate from observable transport selection. ## Query Cache Retention When the last consumer of a `useObservableQuery` cache entry releases it — for example, when the user navigates away — Arc schedules cleanup after `queryCacheRetentionMs` milliseconds (default: 30 seconds). Until then, cached data and the active server subscription remain available. Another consumer acquiring the entry cancels its pending cleanup. This has two practical effects: * **Instant navigation**: If the user returns to the same page within the retention window, cached data renders immediately instead of showing a loading state while the subscription re-establishes. * **Subscription cleanup**: After the window expires without another consumer, the cache entry is evicted and its subscription is torn down. This does not necessarily close a shared hub connection that serves other subscriptions. ```tsx {/* data survives for 60 s after the last subscriber unmounts */} ``` Set the value to `0` to schedule zero-delay cleanup after the last consumer releases the entry. Cleanup is asynchronous, not synchronous eviction during unmount: ```tsx {/* scheduled zero-delay cleanup after the last consumer releases the entry */} ``` The default can also be adjusted globally without the React component: ```typescript import { Globals } from '@cratis/arc'; Globals.queryCacheRetentionMs = 60_000; ``` > **Note:** The retention window applies per cache entry, not globally. Each query type and argument combination has its own independent timer. ## Observable Query Transport Use `queryTransportMethod`, `queryDirectMode`, and `queryConnectionCount` to control observable query connection behavior. For transport semantics, hub behavior, SSE limits, and pooling details, see [Observable Query Multiplexing](/arc/frontend/react/queries/observable-query-multiplexing/). ## Custom EventSource Factory By default, SSE observable query connections use the global `EventSource` constructor. Override it for an environment-specific compatible implementation or browser credentialed cross-origin SSE. The factory receives only the connection URL; callback headers are not passed to it. Set `eventSourceFactory` to substitute your own SSE client without changing anything else about how observable queries work: ```tsx import { Arc } from '@cratis/arc.react'; export const App = () => ( new EventSource(url, { withCredentials: true })}>
Your query components
); ``` The returned value must satisfy the `EventSource` interface expected by Arc. This applies to direct and hub SSE connections, not WebSockets. The server must allow the credentialed origin through CORS. `withCredentials` affects the streaming handshake only: SSE control POSTs are separate fetch calls and currently do not set `credentials: 'include'`. Cross-origin cookie-only hub authentication therefore needs additional, independently verified support; the factory alone does not solve it. The default can also be set globally without the React component: ```typescript import { Globals } from '@cratis/arc'; Globals.eventSourceFactory = (url) => new EventSource(url, { withCredentials: true }); ``` ## Change Stream Transfer Mode `observableQueryTransferMode` is global and is forwarded in shared-hub subscription requests, controlling server emissions as well as `useChangeStream()` fallback processing. Configure it before subscriptions are established; nested `` providers do not isolate modes. | Value | Shared-hub server emissions | `useChangeStream()` fallback without a server changeset | | --- | --- | --- | | `ObservableQueryTransferMode.Delta` | Initial full snapshot, then delta-only collection updates | Compares snapshots locally | | `ObservableQueryTransferMode.Full` | Full snapshots, without server changesets | Treats the processed snapshot as additions | A received server changeset takes precedence over local fallback in either mode. Prefer ordinary observable `.use()` for delta feeds: it reconstructs collections, whereas [observable Suspense currently does not](/arc/frontend/react/queries/suspense-queries/#observable-collections-and-delta-only-updates). For hook-level behavior, see [Change Stream](/arc/frontend/react/queries/change-stream/). ## Related * [Arc Component](/arc/frontend/react/arc/) * [Core Query Configuration](/arc/frontend/core/queries/configuration/) * [Observable Queries](/arc/frontend/react/queries/observable-queries/) # Observable Queries > Use observable queries in React for real-time updates, and choose between hub-multiplexed and direct per-query connections. Observable queries provide real-time updates in React through `useObservableQuery()` and generated proxy helpers. Arc supports both centralized hub routing and direct per-query connections. For backend implementation patterns, see [Controller-based Observable Queries](/arc/backend/csharp/queries/controller-based/observable-queries/), [Model-bound Observable Queries](/arc/backend/csharp/queries/model-bound/observable-queries/), and [Observable Query Hub](/arc/backend/csharp/queries/observable-query-demultiplexer/). ## Observable Query Transport By default, observable queries connect through centralized hub endpoints. Two props on the `` component control this behavior. ### `queryTransportMethod` Selects the transport protocol used for the hub connection. | Value | Description | | --- | --- | | `QueryTransportMethod.ServerSentEvents` | Shared SSE hub at `/.cratis/queries/sse`, with subscribe/unsubscribe POSTs (React default). | | `QueryTransportMethod.WebSocket` | WebSocket hub transport. | ```tsx import { Arc } from '@cratis/arc.react'; import { QueryTransportMethod } from '@cratis/arc/queries'; export const App = () => ( ); ``` ### `queryDirectMode` Controls whether observable queries connect directly to each query’s own URL or route through the centralized hub. * When `false` (default): queries are routed through the centralized hub endpoint (`/.cratis/queries/sse` or `/.cratis/queries/ws` depending on `queryTransportMethod`). * When `true`: each observable query opens its own connection directly to the per-query URL, bypassing the hub entirely. This is useful during local development or when connecting to services that do not expose the centralized hub. ```tsx import { Arc } from '@cratis/arc.react'; export const App = () => ( ); ``` | Prop | Type | Default | Description | | --- | --- | --- | --- | | `queryTransportMethod` | `QueryTransportMethod` | `ServerSentEvents` | Transport used for observable query connections. | | `queryDirectMode` | `boolean` | `false` | When `true`, bypasses the hub and connects directly per query. | ## Changing arguments, paging and sorting Non-Suspense observable hooks select a cache entry by query, arguments, page, page size and sort order. Changing any of these selects a matching subscription or starts one with the new settings. Two consumers on different pages do not share a subscription; matching consumers do. The result immediately shows the matching entry’s cached result, if one exists. Otherwise it resets to the query’s default value with `isReady: false` and `isPerforming: true` until a result arrives. Rows and failure status from the previous entry are not shown as the new result, and later pushes to the previous subscription do not update the component. A retained subscription stays live during the cache retention window. Returning to it shows its latest cached result without another subscribe. See [query instance caching](/arc/frontend/react/queries/query-instance-caching/) for retention settings and [paging](/arc/frontend/react/queries/paging/) for the paging and sorting controls. ## Handle server-side observation failures For both SSE and WebSocket hub connections, a server `Error` message delivers a query result with `isReady: true`, `isSuccess: false`, `hasExceptions: true`, and the server error in `exceptionMessages`. For example, a MongoDB collection watch that cannot start because the database is unreachable reports a failure instead of leaving the hook waiting for its first result. Check `result.hasExceptions` and show the error before treating missing data as an empty result. Normal unsubscribe and watch cancellation do not produce error results. An error result does not itself restart a failed backend observation; recovery requires a new observation. See [MongoDB observation errors and recovery limits](/arc/backend/csharp/mongodb/observing-collections/#errors-and-recovery-limits). ## Related Pages * [Observable Query Multiplexing](/arc/frontend/react/queries/observable-query-multiplexing/) for connection pooling, SSE limits, and protocol behavior. * [Change Stream](/arc/frontend/react/queries/change-stream/) for item-level delta tracking on observable collections. * [Use Observable Queries with cURL](/arc/backend/csharp/queries/using-observable-queries-with-curl/) for backend endpoint debugging workflows. # Observable Query Diagnostics > Inspect the observable-query cache and transport from React with getSnapshot() and snapshots$, and correlate it with backend query health. Observable query diagnostics give you a live view into the query subsystem from React. They are useful when you need to build developer tools, inspect cache behavior, or show the current transport state in an internal dashboard. The diagnostics service is exposed through `ArcContext` as `observableQueryDiagnostics`. It provides two ways to read the current state: * `getSnapshot()` for a point-in-time read. * `snapshots$` for a live stream of updates. Diagnostics snapshots are only exposed through this API surface. Arc does not emit browser `window` events for diagnostics updates. ## What the snapshot contains Each snapshot includes: * transport configuration * multiplexer connection state * cache state and cache size * ownership information for hooks that opted into tracking * a health summary This is intentionally diagnostic data, not application state. Use it for tooling, not for rendering core business screens. ## Watching snapshots from React Subscribe to `snapshots$` when you want live updates. ```tsx import { useContext, useEffect, useState } from 'react'; import { ArcContext } from '@cratis/arc.react'; import { ObservableQueryDiagnosticsSnapshot } from '@cratis/arc/queries'; export const QueryDiagnosticsPanel = () => { const arc = useContext(ArcContext); const [snapshot, setSnapshot] = useState(); useEffect(() => { const diagnostics = arc.observableQueryDiagnostics; if (!diagnostics) { return; } setSnapshot(diagnostics.getSnapshot()); const subscription = diagnostics.snapshots$.subscribe(setSnapshot); return () => subscription.unsubscribe(); }, [arc.observableQueryDiagnostics]); if (!snapshot) { return

No diagnostics available.

; } return (
Connected
{snapshot.health.allQueriesConnected ? 'Yes' : 'No'}
Tracked queries
{snapshot.cache.entryCount}
Owners
{Object.keys(snapshot.ownership.ownersByQueryKey).length}
); }; ``` ## Tracking ownership The query hooks can optionally tag a cache entry with a human-readable owner label. This lets diagnostics show which component is responsible for each tracked query instance. Use the optional `owner` argument on `useQuery()` or `useObservableQuery()` when you want that metadata to appear in the snapshot. ```tsx const [result] = useObservableQuery(MyObservableQuery, args, sorting, true, 'OrdersPage'); ``` If you omit the owner, the diagnostics stream still works. You just get the connection and cache information without ownership labels. ## Correlating with the backend health endpoint The frontend diagnostics show one side of the picture — what the browser cache and transport layer know. When you need to verify that the server is actually pushing data to the right subscribers, Arc provides a matching backend endpoint at `/.cratis/queries/health`. The backend health feed requires authentication outside Development by default. Use a signed-in diagnostic client, including for named subscriptions over WebSocket or SSE, and ensure the caller has any roles configured through `ArcOptions.QueryHealth.Roles`. This does not affect the local frontend diagnostics API. The two surfaces use the same query identifier format. Each entry in `snapshot.cache.entries` has a `queryName` field in the form `{TypeFullName}.{MethodName}`. The backend health endpoint exposes a `querySubscriptions` array where each entry’s `queryName` field uses the exact same string. You can match them by name. A typical investigation flow: 1. Pull `snapshot.cache.entries` from the frontend diagnostics. 2. Find the entry whose `queryName` matches the query you are investigating. 3. Check whether `subscribed` is `true` (the frontend started a subscription and owns its teardown; this is local state, not a server acknowledgement) and whether `hasResult` is `true` (at least one data frame has arrived). 4. Open `/.cratis/queries/health` or subscribe to it in a dev tool component, then look for the matching `queryName` in `querySubscriptions`. 5. Inspect `lastDataServedAt` and `lastPongReceivedAt` to confirm the backend is actively serving data. If the frontend reports `subscribed: true` but `hasResult: false`, and the backend shows a matching subscriber with a stale `lastDataServedAt`, the problem is a data source that has not emitted since the subscription opened. If the frontend reports `subscribed: true` and `hasResult: false`, but the backend shows no matching subscriber, the subscription request did not reach or was not accepted by the backend — look for a connection problem in the multiplexer or SSE transport. If the frontend reports `subscribed: false`, no underlying subscription is currently registered for that entry: it was never started (for example, a disabled `.when()` condition), it was torn down after the last subscriber unmounted, or a connection reset tore it down. Check `subscriberCount` to see whether components still own the entry; a positive count with `subscribed: false` means they are waiting for the subscription to be re-established. See [Query Health Endpoint](/arc/backend/csharp/queries/query-health/) for the full backend API and response shape. ## Related pages * [Observable Queries](/arc/frontend/react/queries/observable-queries/) * [Observable Query Multiplexing](/arc/frontend/react/queries/observable-query-multiplexing/) * [Query Instance Caching](/arc/frontend/react/queries/query-instance-caching/) * [Backend: Query Health Endpoint](/arc/backend/csharp/queries/query-health/) # Observable query multiplexing > Compare direct and shared-hub SSE/WebSocket connections and understand the streaming handshake and control requests. A screen with many live queries does not need one persistent connection per query. Hub mode shares transport connections and routes each result to its subscription. Direct mode remains useful for per-query debugging or hosts without hub endpoints. ## How it works The two configuration choices are independent: | `queryTransportMethod` | `queryDirectMode` | Connection and subscription path | | --- | --- | --- | | `ServerSentEvents` | `false` | Shared EventSource hub at `/.cratis/queries/sse`; subscribe/unsubscribe through POSTs | | `WebSocket` | `false` | Shared WebSocket hub at `/.cratis/queries/ws`; subscribe/unsubscribe through socket messages | | `ServerSentEvents` | `true` | EventSource to each query’s own URL | | `WebSocket` | `true` | WebSocket to each query’s own URL | Paths above are relative to the configured origin/API base path. `` defaults to SSE hub mode with one connection slot. The core `Globals` transport default, before React bindings initialize it, is WebSocket. ### SSE hub connection The streaming GET does **not** subscribe with `?query=Name`. After the connection handshake supplies a connection ID, the client submits control requests: ``` HubBrowserHubBrowserConnection ID, subscription ID, query name and argumentsGET /.cratis/queries/sseconnected message with connection IDPOST /.cratis/queries/sse/subscribeSSE result messages tagged with subscription IDPOST /.cratis/queries/sse/unsubscribe ``` Multiple subscriptions share that EventSource. Reconnection and resubscription are managed by the client. For payloads and server authorization, use the [hub protocol reference](/arc/backend/csharp/queries/observable-query-demultiplexer/). ### WebSocket hub connection The client sends typed subscription messages over the shared WebSocket and routes incoming results by subscription ID. It does not issue SSE control POSTs. Both transport handshakes are subject to browser credential restrictions; see [HTTP headers](/arc/frontend/react/arc/#http-headers-callback). ## Configuring transport and mode This configuration fragment selects direct SSE; direct mode does not implicitly mean WebSocket: ```tsx import { Arc } from '@cratis/arc.react'; import { QueryTransportMethod } from '@cratis/arc/queries'; export const App = () => (
Your query components
); ``` For normal shared-hub use, leave `queryDirectMode` false. `queryConnectionCount` configures pool slots, not subscriptions; direct mode does not use the pool. ### SSE connection limit (HTTP/1.1) The client unconditionally caps SSE **hub** connections at four and warns if you request more. This leaves room for control POSTs and ordinary fetches in browsers with HTTP/1.1 per-origin connection limits. HTTP/2 changes network multiplexing but **does not remove Arc’s current four-slot cap**. Direct SSE connections are not protected by this pool cap. WebSocket hub pools do not use this SSE cap. More slots are not automatically faster; measure your workload before increasing the count. ## Provider ownership The multiplexer is module-global, not isolated per nested ``. Changing its origin/service/configuration key disposes the previous shared multiplexer. Do not use provider nesting as a guarantee of simultaneous independent authenticated hubs. See [provider limitations](/arc/frontend/react/arc/#multiple-microservices-in-one-frontend). ## Controlling change-stream transfer mode `observableQueryTransferMode` is included in shared-hub subscription requests. Full mode asks the server for full snapshots; Delta mode sends an initial snapshot followed by delta-only collection updates. The same global setting controls fallback handling in `useChangeStream`; server-provided changesets take precedence over that fallback. Set it before establishing subscriptions, not as a per-provider isolation mechanism. It does not select SSE versus WebSocket. See [change streams](/arc/frontend/react/queries/change-stream/) and the [observable Suspense delta limitation](/arc/frontend/react/queries/suspense-queries/#observable-collections-and-delta-only-updates). ## See also * [Query configuration](/arc/frontend/react/queries/configuration/) * [Query instance caching](/arc/frontend/react/queries/query-instance-caching/) * [Backend observable hub](/arc/backend/csharp/queries/observable-query-demultiplexer/) * [Vite configuration](/arc/frontend/react/vite-configuration/) # Paging > Page enumerable query results in React with useWithPaging and useSuspenseWithPaging, backed by server-side paging. Enumerable query proxies expose paging helpers. The backend must implement paging: ordinary automatic pipeline paging uses `IQueryable`, while observable providers can apply paging themselves (for example MongoDB `Observe()` uses the query context’s Skip/Limit). ## How Paging Works When the backend returns `IQueryable`, the query pipeline applies `.Skip()` and `.Take()` on the server so only the requested page is fetched. Generated proxies expose `useWithPaging` and `useSuspenseWithPaging` for this flow. For backend implementation details, see [Controller-based Paging](/arc/backend/csharp/queries/controller-based/paging/) and [Model-bound Paging](/arc/backend/csharp/queries/model-bound/paging/). ## Enabling Paging Use `useWithPaging` instead of `use`, passing a page size. This example assumes a generated, non-observable `AllAccounts` query proxy whose items have `id`, `name`, and `balance`; adjust the import to your proxy output path: ```tsx import { AllAccounts } from './Accounts/AllAccounts'; export const AccountList = () => { const [result, perform, setSorting, setPage, setPageSize] = AllAccounts.useWithPaging(25); return ( <> {result.data.map(account => ( ))}
NameBalance
{account.name} {account.balance}

Page {result.paging.page + 1} of {result.paging.totalPages} ({result.paging.totalItems} total items)

); }; ``` ## Return Tuple for Paged Queries For an **ordinary** query, `useWithPaging` returns an extended tuple: | Index | Name | Type | Description | | --- | --- | --- | --- | | 0 | `result` | `QueryResultWithState` | Query result including paging metadata | | 1 | `perform` | `() => Promise` | Re-execute the query | | 2 | `setSorting` | `(sorting: Sorting) => Promise` | Change sort field and direction | | 3 | `setPage` | `(page: number) => Promise` | Navigate to a specific page (zero-based) | | 4 | `setPageSize` | `(pageSize: number) => Promise` | Change number of items per page | For an **observable enumerable** query the tuple is `[result, setSorting, setPage, setPageSize]`; there is no `perform` delegate. Single-result proxies do not generate paging helpers. Suspense variants preserve these tuple shapes. ### Cached observable subscriptions In non-Suspense observable hooks, `setPage`, `setPageSize`, and `setSorting` move to a cache entry for the requested paging and sorting. Each new subscription sends those settings to the server. Consumers on different pages or sort orders have independent subscriptions; consumers with matching query arguments, paging and sorting share one. While the new page is pending, the result shows its cached data if available. Otherwise it shows the query’s default value with `isReady: false` and `isPerforming: true`, not the previous page’s rows. Returning to a retained entry reuses its cached result and live subscription without subscribing again. Ordinary paged queries also reset to the matching entry, but perform a fresh request and mark cached data as `isPerforming: true` until it settles. Suspense uses separate resource caches. See [query instance caching](/arc/frontend/react/queries/query-instance-caching/) for retention and sharing. ## Paging Metadata Paging information is available on `result.paging`: | Property | Type | Description | | --- | --- | --- | | `page` | `number` | Current zero-based page number | | `size` | `number` | Items per page | | `totalItems` | `number` | Total items across all pages | | `totalPages` | `number` | Total number of pages | ## Hook Variants with Paging | Hook | Description | | --- | --- | | `MyQuery.useWithPaging(pageSize)` | Standard query with paging | | `MyQuery.useSuspenseWithPaging(pageSize)` | Suspense-compatible query with paging | | `MyObservableQuery.useWithPaging(pageSize)` | Observable paging with a subscription for the selected page and sort order | Parameterized proxies accept `(pageSize, args?, sorting?)`; parameterless enumerable proxies accept `(pageSize, sorting?)`. Match the generated signature rather than inserting an arguments placeholder for a parameterless query. ## Sorting with Paging Sorting is independent of paging. Changing sort order fetches the matching ordinary-query result or selects the matching observable subscription. The following ordinary-query fragment shows initial sorting and a dynamic change. ```tsx import { Sorting, SortDirection } from '@cratis/arc/queries'; // Initial sorting const [result] = AllAccounts.use(new Sorting('name', SortDirection.ascending)); // Change sorting dynamically const [paged, perform, setSorting] = AllAccounts.useWithPaging(25); await setSorting(new Sorting('balance', SortDirection.descending)); ``` ## Important Requirement Ordinary automatic query-pipeline paging requires `IQueryable`. A materialized `IEnumerable` or `List` does not gain automatic server slicing merely because a paging hook exists. An application/provider may explicitly consume paging context, including for observable `ISubject` results. Verify both returned rows and paging metadata against that provider. ## See Also * [Core Query Usage](/arc/frontend/react/queries/usage/) * [Suspense Queries](/arc/frontend/react/queries/suspense-queries/) * [Backend Query Pipeline](/arc/backend/csharp/queries/query-pipeline/) # Query instance caching > Understand instance reuse, observable subscription sharing, and the default 30-second retention window. Navigating away from a live list and back should not always start from an empty screen. Arc retains query instances and last-known data, but instance reuse and request deduplication are different contracts. ## How it works `` creates one `QueryInstanceCache` per mount. Keys use the generated stable `queryName` and JSON-serialized arguments with top-level keys sorted. `useQuery`, `useQueryWithPaging`, `useObservableQuery` and `useObservableQueryWithPaging` add the current page, page size and sort order to that key. No key includes origin or credentials, so do not treat the cache as an isolation boundary for independent views or users. | Hook family | Shared instance | Network/results | | --- | --- | --- | | `useObservableQuery` / `useObservableQueryWithPaging` | Only for the same query, arguments, page and sort order | Shares subscription ownership and registers cache listeners for updates | | `useQuery` / `useQueryWithPaging` | Only for the same query, arguments, page and sort order | Each enabled mount performs a request; a superseded mount adopts the superseding result, with no cache-listener broadcast | | Suspense hooks | Separate module-level resource caches | See [Suspense queries](/arc/frontend/react/queries/suspense-queries/); not governed by this cache’s retention setting | An ordinary query cache hit can seed a new component with successful cached data, but it does not eliminate the new request. ### Ordinary queries are isolated by arguments, paging and sorting Two components using the same ordinary query with different arguments, pages or sort orders get separate instances. Neither fetches the other’s page or aborts the other’s request. When a component changes its arguments, page, page size or sort order, it moves to the matching entry and fetches again. The result immediately shows that entry’s cached data with `isPerforming: true`, or the query’s default value with `isReady: false` and `isPerforming: true` if nothing is cached. Data and failure status from the previous key are not carried over. A successful result is cached only under the key it answers, so the previous arguments keep their own last known result. Components that agree on all of them share one instance, and an instance runs one request at a time. If the first component’s request is still in flight when the second component mounts, the second request aborts it. The first component then adopts the outcome of the request that superseded it — its data or its failure — instead of staying on its initial, never-ready state. See [query scope](/arc/frontend/react/queries/scope/#when-an-ordinary-query-stops-performing) for what happens when either component unmounts first. Adoption follows that one superseding request; it is not a broadcast. Only a request for the same arguments is adopted, so an explicit `perform` with other arguments updates only the component that called it, and so does any request made while no other component is waiting. `useObservableQuery` differs: its cache listeners push every update to all mounted consumers of an entry. ### Observable queries share only matching pages and sort orders Observable consumers with different arguments, pages, page sizes or sort orders use separate instances and subscriptions. Changing any of these moves the component to the matching entry and subscribes if that entry has no active subscription. The result immediately shows that entry’s cached result unchanged, or the query’s default value with `isReady: false` and `isPerforming: true` until the first result arrives. Updates from the previous entry do not reach the component after it moves. Returning to an entry within its retention window reuses its cached result and live subscription. Consumers with matching keys share that subscription and receive its updates together. ## Lifecycle When the last consumer releases an entry, Arc schedules eviction after `queryCacheRetentionMs`, **30,000 ms by default**. Cached data and an established observable subscription remain alive during that window. A new consumer cancels pending eviction and can reuse them. On expiry the entry is removed and its subscription torn down. ``` consumer returns expires First consumer mounts Create entry and subscribe Additional consumers share entry Last consumer releases 30-second retention timer Unsubscribe and evict ``` Configure retention once when mounting Arc: ```tsx import { Arc } from '@cratis/arc.react'; export const App = () => (
Your query components
); ``` A value of zero schedules eviction without the retention delay. A cache entry expiring does not imply every pooled hub connection immediately closes. ## React StrictMode compatibility Reacquiring an entry cancels its cleanup timer. Provider teardown also defers disposal so an immediate remount can cancel it. That provider-disposal mechanism is distinct from the per-entry retention window; normal last-consumer release is not always `setTimeout(0)`. ## Relationship with the conditional when hook Disabled non-Suspense hooks still create/look up and acquire cache entries. The condition suppresses their automatic request/subscription, not allocation or cache access. Another consumer can already own a live subscription for the same entry. A previously established subscription can also survive through retention. Use the explicit condition to choose UI, rather than treating `hasData` as an enabled flag. Generated single-result defaults are `{}`, so `hasData` can be true without loaded data. See [conditional queries](/arc/frontend/react/queries/conditional-queries/). ## Trust and ownership limits Cached UI data can outlive its last viewer. Clearing identity or reconnecting observables is not a data purge, and nested providers do not isolate the global transport multiplexer. Treat account-switching and sensitive-data eviction as an explicit application concern. See [Arc provider boundaries](/arc/frontend/react/arc/). ## See also * [Observable query multiplexing](/arc/frontend/react/queries/observable-query-multiplexing/) * [Query diagnostics](/arc/frontend/react/queries/observable-query-diagnostics/) * [Query usage](/arc/frontend/react/queries/usage/) # Query scope > Show local query activity using explicit state callbacks, without assuming nested scopes propagate notifications. A section can contain several queries but only need one loading indicator. Wrap that section in `QueryScope` and use its `setIsPerforming` callback to drive React state. `` does not mount a query scope for you. ## Using query scope in React This composition fragment accepts a query-consuming panel as its child: ```tsx import { useState, type ReactElement } from 'react'; import { QueryScope } from '@cratis/arc.react/queries'; export function QueryPanel({ children }: { children: ReactElement }) { const [isPerforming, setIsPerforming] = useState(false); return (

{isPerforming ? 'Loading…' : ''}

{children}
); } ``` The non-Suspense query hooks notify while starting requests/subscriptions. Ordinary queries track the request; observable queries track the wait for the first result, not the lifetime of the connection. A long-lived subscription is not permanently “loading.” Current Suspense hooks use separate resources and do not call QueryScope’s performing notifications; use their loading boundary instead. ## When an ordinary query stops performing Each execution of `useQuery` or `useQueryWithPaging` tells its scope it stopped performing exactly once. That happens at whichever comes first: * **It settles.** The request returned a result, or it was superseded and the component adopted the result of the request that superseded it. * **It fails.** The request rejected or the network failed. * **It is retired.** The component unmounted, or it started a newer request — a new argument, page or sort order, or an explicit `perform` call. The newer request becomes the one the scope tracks. Retiring never aborts a request another component is waiting on. When two components share a query and arguments, one component’s request can supersede the other’s. If the component that started the newer request unmounts, the remaining component still receives that request’s result. If the waiting component unmounts, the request carries on for the component that started it. Either way, the scope of a component that left does not stay “loading.” ## Hierarchical scopes Queries notify the nearest scope. An outer scope’s `isPerforming` **getter** includes child scopes, but a child’s start/completion notification does not call the parent’s `setIsPerforming` callback. Consequently, a toolbar driven by the parent’s callback does not automatically update for child-only activity. Keep queries under one scope when you need one reactive indicator, or explicitly combine each section’s callback state. Parent notification propagation is a runtime follow-up candidate; getter aggregation alone is not reactive aggregation. ## Query scope API | Member | Contract | | --- | --- | | `isPerforming` | Imperative read of own or child activity | | `parent` | Parent scope, if nested | | `addChildScope(scope)` | Registers a child for getter aggregation | | `notifyPerformingStarted()` | Increments own activity count; notifies own callback on transition to active | | `notifyPerformingCompleted()` | Decrements own count; notifies own callback when it reaches zero | | `useQueryScope()` | Returns the nearest scope; does not subscribe to changes in its internals | ## Using query scope in view models MVVM can inject `IQueryScope`, and a view-model getter can read `scope.isPerforming`. However, `withViewModel` making the view model observable does not make the injected scope’s mutable internals observable. For a reactive display, bridge explicit scope callbacks to state rather than relying on a computed getter alone. Continue with [query usage](/arc/frontend/react/queries/usage/) or [command scopes](/arc/frontend/react/commands/scope/). # Suspense queries > Move initial waiting into React boundaries while retaining explicit validation handling and understanding retry limitations. Suspense lets a list delegate initial waiting to a surrounding loading boundary. Arc supplies Suspense variants for ordinary and observable queries, but they do not turn every unsuccessful result into a thrown error. ## How it works A pending resource throws a Promise. React renders the Suspense fallback and retries rendering when it settles. Ordinary query resources throw `QueryFailed` for results with exceptions and `QueryUnauthorized` for authorization failure. Invalid validation results can still resolve normally: inspect `isValid`, `validationResults`, readiness, and success in your component. Observable Suspense waits for the first **ready** result over either SSE or WebSocket. Initial exception/authorization failures reject the resource. Subsequent successful emissions update it without suspending again. Current observable handling does not promote later exception/authorization messages into new boundary failures after fulfillment; do not rely on this boundary as a connection-health or continuous-authorization monitor. ## Observable collections and delta-only updates Caution Observable Suspense currently stores each emission’s data directly; it does **not** reconstruct a collection from delta-only `changeSet` updates. In the default shared-hub Delta mode, the first full snapshot can render correctly and the next update can empty the displayed list. For delta feeds, prefer the ordinary observable `.use()` hook and render its loading state explicitly: it applies the changeset to the previous cached collection. Use observable Suspense for collections only with a verified full-snapshot source. The ordinary (non-observable) Suspense example below is not affected. `observableQueryTransferMode` is not just a client display setting: the shared hub receives it in subscription requests and the server chooses full snapshots or delta emissions accordingly. Configure it before establishing subscriptions; it is global, not isolated by nested `` providers. See [transfer-mode configuration](/arc/frontend/react/queries/configuration/#change-stream-transfer-mode). ## Complete consumer example This consumer fragment assumes an ordinary enumerable generated `AllItems` proxy with string `id` and `name` fields, rendered beneath ``: ```tsx import { QueryBoundary, QueryFailed } from '@cratis/arc.react/queries'; import { AllItems } from './generated/AllItems'; function ItemList() { const [result, perform] = AllItems.useSuspense(); if (!result.isReady) return

Result not ready.

; if (!result.isValid) return

Check the query inputs.

; if (!result.isSuccess) return

Could not load items.

; return (
    {result.data.map(item =>
  • {item.name}
  • )}
); } export function Items() { return ( Loading…

} onError={({ error, isQueryUnauthorized }) => (

{isQueryUnauthorized ? 'You are not authorized.' : error instanceof QueryFailed ? 'The query failed on the server.' : 'An unexpected error occurred.'}

)}>
); } ``` Use `error instanceof QueryFailed` before reading `error.exceptionMessages`. The separate `isQueryFailed` boolean does not narrow the `Error` type in TypeScript. Exception details may be redacted; do not promise or expose a full server stack trace to every reader. ## Boundary components `QueryErrorBoundary` catches render errors. Pair it with React’s ``, or use `QueryBoundary`, which combines both. | Prop | Component | Meaning | | --- | --- | --- | | `children` | Both | Protected subtree | | `onError(info)` | Both | Error renderer; takes precedence over static fallback | | `fallback` | Both | Static error UI | | `loadingFallback` | `QueryBoundary` | Suspense loading UI, default null | `QueryErrorInfo` contains `error: Error`, `isQueryFailed`, `isQueryUnauthorized`, and `reset()`. Reset clears **boundary state only**. ## Hooks Generated `.useSuspense()` argument positions and tuples match `.use()`. Only enumerable proxies generate `.useSuspenseWithPaging()`: | Kind | Tuple | | --- | --- | | Ordinary | `[result, perform, setSorting]` | | Ordinary paged | `[result, perform, setSorting, setPage, setPageSize]` | | Observable enumerable | `[result, setSorting]` | | Observable paged | `[result, setSorting, setPage, setPageSize]` | | Observable single | `[result]` | Raw hooks are `useSuspenseQuery`, `useSuspenseQueryWithPaging`, `useSuspenseObservableQuery`, and `useSuspenseObservableQueryWithPaging`. Their arguments begin with the query constructor; paged raw hooks take a `Paging` instance next. Prefer [generated signatures](/arc/frontend/react/queries/usage/#return-tuple) for application code. ## Cache and re-fetching Suspense uses module-level resource caches so in-flight work survives renders that suspend before committing. These are separate from `QueryInstanceCache` and its 30-second retention option. They are not per-provider authenticated data isolation. For a successfully rendered ordinary query, `perform()` deletes that resource and requests another render. Its current implementation reuses the hook’s arguments; supply changed arguments to the hook rather than relying on an argument passed to the refresh delegate. Sorting/page changes select a new resource key. **Reset is not retry.** A first-render rejected resource can remain in the cache because no effect committed to clean it up. Clicking `reset()` alone can immediately throw the same rejection without issuing a new request. Fine-grained failed-resource invalidation and reliable first-load retry are runtime follow-up candidates. Until an application has a tested recovery strategy, use ordinary hooks for screens requiring explicit recoverable error controls. `clearSuspenseQueryCache()` and `clearSuspenseObservableQueryCache()` are exported for test isolation. They clear global resources, not one component’s failed query; do not casually wire them to a local Retry button. ## See also * [Conditional queries](/arc/frontend/react/queries/conditional-queries/) * [Query instance caching](/arc/frontend/react/queries/query-instance-caching/) * [Observable transports](/arc/frontend/react/queries/observable-query-multiplexing/) # Core Query Usage > Read data in React through generated query proxies, with the hook tuples, argument positions, and result state each query shape returns. Use generated query proxies to retrieve data in React with strong typing and predictable state. ## Proxy Generation Arc generates query proxies from backend queries (controller-based and model-bound). These proxies expose static React helpers such as `.use()` and `.useWithPaging()`. See [Backend Proxy Generation](/arc/backend/csharp/proxy-generation/) for setup details. ## Basic Query Hook From a React component, call the generated static `use()` method: ```typescript export const MyComponent = () => { const [accounts, queryAccounts] = AllAccounts.use(); return ( <> ); }; ``` All results are strongly typed from backend metadata. ## Return Tuple Use the tuple shape emitted for your backend result. Omitting trailing elements while destructuring is fine; treating the tuple itself as the model is not. | Proxy shape | `.use()` / `.useSuspense()` | `.useWithPaging()` / `.useSuspenseWithPaging()` | | --- | --- | --- | | Ordinary single or enumerable | `[result, perform, setSorting]` | Enumerable only: `[result, perform, setSorting, setPage, setPageSize]` | | Observable enumerable | `[result, setSorting]` | `[result, setSorting, setPage, setPageSize]` | | Observable single | `[result]` | Not generated | Parameterized proxies emit `NameParameters`, not `INameParameters`. Their calls take `args` first and, for enumerables, optional `sorting` next. Parameterless enumerable calls take `sorting` first; parameterless single-result calls take no arguments. Paging prepends `pageSize` to these argument positions. For example, `BooksForAuthor.use({ authorId })` passes a named argument object, whereas parameterless `AllBooks.use(sorting)` does not take an `undefined` argument placeholder. Observable queries differ and are covered in [Observable Queries](/arc/frontend/react/queries/observable-queries/). ## QueryResultWithState The query result type in React is `QueryResultWithState`. It implements the same `IQueryResult` contract as `QueryResult` and adds React-focused execution state. Properties from the shared `IQueryResult` contract: | Property | Description | | --- | --- | | `data` | The actual data returned in the expected type. | | `isSuccess` | Whether the query completed successfully. | | `isReady` | Whether the query has produced a result. `false` is transient, not a failure. | | `isAuthorized` | Whether the query was authorized. | | `isValid` | Whether the query input was valid. | | `validationResults` | Validation errors returned by the backend. | | `hasExceptions` | Whether exceptions were returned. | | `exceptionMessages` | Exception messages from the backend. | | `exceptionStackTrace` | Exception stack trace when available. | | `paging` | Paging metadata (page, size, totals) when paging is enabled. | React execution state: | Property | Description | | --- | --- | | `isPerforming` | Whether the query is currently fetching data. | ### Readiness and performing state An observable query HTTP request can return `202 Accepted` with `isReady: false` before the observable has produced its first result. This is a transient server state: `isSuccess` is false, but `hasExceptions` remains false. Inspect `isReady` before treating an unsuccessful result as a failure. `isReady` and `isPerforming` describe different things. Readiness says whether a result has been produced; performing says whether the client is currently fetching or subscribing. Initial React query state is not ready, while disabled or synthetic failure states are ready because no server result remains pending. `IQueryResult.isReady` is optional for source compatibility: older Arc servers and third-party structural query-result objects may omit it. Arc normalizes query results received through its HTTP, WebSocket, and Server-Sent Events transports to `isReady: true` when the member is absent, while preserving an explicit `false`. SDK-produced `QueryResult` and `QueryResultWithState` instances always expose a concrete boolean. ## Query Parameters Queries can expose parameters on the backend — in C# through attributes such as `[FromQuery]` and route parameters, on the JVM through ordinary query-method parameters, and in TypeScript through `@query(argument(...))` on a static read-model method. These become typed arguments on generated proxies. * C# ```csharp [HttpGet("starting-with")] public IEnumerable StartingWith([FromQuery] string? filter) { var filterDocument = Builders .Filter .Regex("name", $"^{filter ?? string.Empty}.*"); return _collection.Find(filterDocument).ToList(); } ``` * Java ```java @ReadModel public record DebitAccount(AccountId id, AccountName name) { public static List startingWith( String filter, @FromServices DebitAccountRepository accounts ) { return accounts.findByNameStartingWith(filter == null ? "" : filter); } } ``` * Kotlin ```kotlin @ReadModel data class DebitAccount(val id: AccountId, val name: AccountName) { companion object { @JvmStatic fun startingWith( filter: String?, @FromServices accounts: DebitAccountRepository ): List = accounts.findByNameStartingWith(filter ?: "") } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { argument, query, readModel, service } from '@cratis/arc.core'; @readModel() export class DebitAccount { @field(AccountId) id!: AccountId; @field(AccountName) name!: AccountName; @query(argument('filter', String, { optional: true }), service(DebitAccountRepository)) static startingWith(filter: string | undefined, accounts: DebitAccountRepository): Promise { return accounts.findByNameStartingWith(filter ?? ''); } } ``` The C# tab shows a controller action, because that is the style this attribute-driven parameter binding belongs to. The JVM has no controller-based discovery, so its parameters are simply the query method’s own, declared on the `@ReadModel`’s companion object. The TypeScript tab declares its argument explicitly with `argument('filter', String, { optional: true })`. All tabs use the shared frontend query runtime; their generated imports and paths depend on each backend’s configuration. Use the parameterized proxy from React: ```typescript export const MyComponent = () => { const [accounts, queryAccounts] = StartingWith.use({ filter: '' }); return ( <> ); }; ``` An enabled hook fetches again whenever any argument changes — optional arguments such as `filter` as well as required ones. Arguments are compared by their serialized value, so passing a new object with the same values does not trigger a request. Each set of arguments also gets its own cached result; see [query instance caching](/arc/frontend/react/queries/query-instance-caching/). ## HTTP Headers Ordinary query fetches use `httpHeadersCallback`. Native EventSource/WebSocket handshakes cannot attach those arbitrary headers; SSE control POSTs are separate fetch requests. Cookies are browser-managed, not configurable `Cookie` request headers. See the [transport credentials matrix](/arc/frontend/react/arc/#http-headers-callback). ## See Also * [Paging](/arc/frontend/react/queries/paging/) * [Suspense Queries](/arc/frontend/react/queries/suspense-queries/) * [Conditional Queries](/arc/frontend/react/queries/conditional-queries/) * [Observable Queries](/arc/frontend/react/queries/observable-queries/) # Story Components > Why and how to use Arc.React Story Components for consistent Storybook stories, and how to load their stylesheet. ## Overview The Arc.React Story Components library provides a comprehensive set of reusable components and utilities designed specifically for creating beautiful, consistent Storybook stories. These components ensure that all your stories have a uniform look and feel while automatically adapting to both dark and light themes. ## Motivation When building component libraries, documentation through Storybook is essential. However, creating stories that look professional and consistent across different themes can be time-consuming and repetitive. Common challenges include: * **Inconsistent Spacing**: Different stories use different padding and margins, making the documentation feel disjointed * **Theme Support**: Manual theme handling requires duplicate code and careful maintenance * **Layout Patterns**: Repeating the same container, grid, and section patterns across multiple stories * **Visual Hierarchy**: Ensuring consistent typography, colors, and visual structure * **Developer Experience**: Spending more time on story presentation than on showcasing the actual components ## Solution The Story Components library solves these problems by providing: ### Consistency Pre-built components with standardized spacing, sizing, and layout patterns ensure every story looks cohesive. Developers can focus on showcasing their components rather than styling containers. ### Automatic Theme Adaptation Story components read their colors from CSS variables with a dark and a light palette. Your Storybook selects the palette by setting `data-theme` on the page; the stylesheet does not follow Storybook’s theme or background by itself. See [theme support](/arc/frontend/react/stories/theme-support/#switching-themes) for wiring the backgrounds toolbar to it. ### Reusable Patterns Common UI patterns like containers, grids, sections, badges, and dividers are available as ready-to-use components, reducing boilerplate and improving maintainability. ### Beautiful by Default Thoughtfully designed defaults for colors, shadows, borders, and spacing mean your stories look professional out of the box. The stylesheet behind those defaults ships inside the package. Add one import to your Storybook preview and every component below is styled - see [Styling](/arc/frontend/react/stories/styling/). ## Getting Started Load the stylesheet once, in your Storybook preview: .storybook/preview\.ts ```ts import '@cratis/arc.react/stories/styles.css'; ``` Then import the components you need in your story files: ```tsx import { StoryContainer, StorySection, StoryGrid, StoryBadge, StoryDivider, } from '@cratis/arc.react/stories'; export const MyStory: Story = { render: () => (

My Component

), }; ``` ## What’s Included * **[Components](/arc/frontend/react/stories/components/)**: Pre-built React components for common story layouts * **[Styling](/arc/frontend/react/stories/styling/)**: CSS variables and utility classes for consistent theming * **[Theme Support](/arc/frontend/react/stories/theme-support/)**: Automatic dark/light mode adaptation ## Examples For comprehensive examples of all components and patterns, see the `StoryContainer.stories.tsx` file in the source code. # Story Components Reference > Reference for the Story Components available for building consistent Storybook stories, with their props. This page documents all available components for building Storybook stories. ## StoryContainer A container component for wrapping stories with consistent spacing and styling. ### Props * `children`: React.ReactNode - The content to render * `size`: ‘sm’ | ‘md’ | ‘lg’ | ‘full’ - Container size (default: ‘md’) * `sm`: 600px max width * `md`: 1200px max width * `lg`: 1400px max width * `full`: currently uses the same container class as `md` (1200px max width), not unrestricted width. A distinct full-width style is a runtime follow-up candidate. * `asCard`: boolean - Render as a card with background and border (default: false) * `className`: string - Additional CSS classes ### Example ```tsx import { StoryContainer } from '@cratis/arc.react/stories'; export const MyStory: Story = { render: () => (

My Component

), }; ``` ### Usage Guidelines * Use `size="sm"` for focused examples with narrow content * Use `size="md"` (default) for most standard stories * Use `size="lg"` for complex layouts or multiple columns * `size="full"` is currently capped at 1200px like `md`; it does not provide unrestricted full-width demonstrations * Add `asCard` when you want visual separation from the background ## StorySection A section component for grouping related content with consistent vertical spacing. ### Props * `children`: React.ReactNode - The content to render ### Example ```tsx

First Section

Content here

Second Section

More content

``` ### Usage Guidelines * Use sections to separate distinct topics or examples within a story * Sections provide consistent vertical spacing between content blocks * Ideal for multi-part demonstrations ## StoryGrid A responsive grid container for displaying multiple items. ### Props * `children`: React.ReactNode - Grid items to render ### Example ```tsx
Card 1
Card 2
Card 3
``` ### Usage Guidelines * Use grids to showcase variations or multiple states of a component * Grid automatically adjusts columns based on screen size * Combine with `.story-card` class for card-style grid items ## StoryDivider A visual divider between sections. ### Props No props - it’s a simple visual element. ### Example ```tsx

First section

Second section

``` ### Usage Guidelines * Use dividers to create clear visual separation * Automatically adapts color to current theme * Lighter alternative to separate sections ## StoryBadge A status badge component for displaying colored labels. ### Props * `children`: React.ReactNode - Badge content * `variant`: ‘success’ | ‘warning’ | ‘error’ | ‘info’ - Badge color variant * `className`: string - Additional CSS classes ### Example ```tsx

Build Status: Passing

``` ### Variants * `success`: Green badge for positive states * `warning`: Yellow/orange badge for cautionary states * `error`: Red badge for error states * `info`: Blue badge for informational states ### Usage Guidelines * Use badges to highlight status, state, or metadata * Keep badge content concise (1-2 words) * Choose variants that match semantic meaning # Styling Reference > Reference for the stylesheet, CSS variables, and utility classes behind Arc.React Story Components. The story components use a comprehensive set of CSS variables and utility classes for consistent theming. ## Where the styling comes from Everything on this page is defined in a single stylesheet that ships inside `@cratis/arc.react`. Import it once, in your Storybook preview: .storybook/preview\.ts ```ts import '@cratis/arc.react/stories/styles.css'; ``` That is the whole setup. Import it before your own theme so your rules win, and every variable and class below becomes yours to override from ordinary CSS. The components do not import the stylesheet themselves, and that is deliberate. Doing so would put the stylesheet’s path into the package’s compiled JavaScript, which only resolves when the package is built by the bundler that copies the file alongside it - and not when a TypeScript project reference builds it, where no such copy happens. One import you can see beats an import that works in some builds and not others. ## CSS Variables The color variables switch between the dark and light palettes when the page’s `data-theme` attribute changes; see [theme support](/arc/frontend/react/stories/theme-support/#switching-themes). Spacing, radius, and shadow variables are shared by both palettes. ### Colors #### Text Colors * `--color-text`: Primary text color * `--color-text-secondary`: Secondary text color for less important content * `--color-text-muted`: Muted text color for hints and labels #### Background Colors * `--color-background`: Main background color * `--color-background-secondary`: Secondary background (cards, inputs) * `--color-background-tertiary`: Tertiary background (hover states) #### Border Colors * `--color-border`: Standard border color * `--color-border-light`: Lighter border color for subtle divisions #### Brand Colors * `--color-primary`: Primary brand color * `--color-primary-hover`: Primary color hover state #### Semantic Colors * `--color-success`: Success state color (typically green) * `--color-warning`: Warning state color (typically yellow/orange) * `--color-error`: Error state color (typically red) * `--color-info`: Info state color (typically blue) ### Spacing Consistent spacing values for padding and margins: * `--space-xs`: 0.25rem (4px) * `--space-sm`: 0.5rem (8px) * `--space-md`: 1rem (16px) * `--space-lg`: 1.5rem (24px) * `--space-xl`: 2rem (32px) * `--space-2xl`: 3rem (48px) ### Visual Effects #### Border Radius * `--radius-sm`: Small border radius * `--radius-md`: Medium border radius * `--radius-lg`: Large border radius #### Box Shadows * `--shadow-sm`: Subtle shadow for slight elevation * `--shadow-md`: Medium shadow for cards and modals * `--shadow-lg`: Large shadow for prominent elements ### Typography * `--font-sans`: Sans-serif font family for UI text * `--font-mono`: Monospace font family for code ## Utility Classes Pre-built CSS classes you can use directly in your stories. ### Container Classes * `.story-container`: Standard container with medium width * `.story-container-sm`: Small container (600px max width) * `.story-container-lg`: Large container (1400px max width) ### Layout Classes * `.story-section`: Section wrapper with consistent vertical spacing * `.story-grid`: Responsive grid layout * `.story-card`: Card styling with background, border, and shadow ### Visual Elements * `.story-divider`: Horizontal divider line ### Badge Classes * `.story-badge`: Base badge style * `.story-badge-success`: Green success badge * `.story-badge-warning`: Yellow/orange warning badge * `.story-badge-error`: Red error badge * `.story-badge-info`: Blue info badge ## Usage Examples ### Using CSS Variables ```tsx
Custom styled content
``` ### Using Utility Classes ```tsx

Card Title

Card content

``` ### Combining Variables and Classes ```tsx
Custom card with primary border
``` ## Best Practices 1. **Prefer CSS Variables**: Use variables instead of hard-coded colors for theme compatibility 2. **Use utility classes**: Reuse the shared classes for common spacing and layout patterns 3. **Semantic Colors**: Use semantic color variables (`--color-success`, etc.) for meaningful states 4. **Consistent Spacing**: Stick to the spacing scale (`--space-*`) for consistent rhythm 5. **Theme Testing**: Always test your stories in both dark and light modes # Automatic Theme Support > How Story Components switch between dark and light palettes through the data-theme attribute, and how to wire that to Storybook. Story components read theme CSS variables, so their colors follow the selected dark or light theme. ## How It Works Almost every color in the story components is read from a CSS custom property rather than written into a rule. The exception is the translucent badge backgrounds, which are fixed `rgba(...)` values; override `.story-badge-*` if you need them to match a custom palette. The stylesheet that ships with the package declares those properties twice - once for dark, once for light - so changing the theme changes one attribute and every component follows. ### Default Theme The default theme is **dark mode**. This ensures a consistent baseline experience when viewing stories. ### Switching Themes Set `data-theme` on `` or ``. `data-theme="light"` selects the light palette, `data-theme="dark"` selects the dark one, and no attribute at all leaves you with dark. Because the properties are inherited, the attribute can also sit on a container to theme one part of a page: ```html ``` The package deliberately does **not** read the operating system color scheme on its own. The palette has to agree with whatever paints the canvas behind it - the Storybook background, your own preview decorator - and only your Storybook configuration knows what that is. Deciding for you would produce dark text on a dark canvas for anyone whose machine disagreed with their toolbar. The Arc repository’s own Storybook wires the two together: a snippet in its `.storybook/preview-head.html` watches the canvas background, measures its luminance, and sets `data-theme` to match - so the backgrounds toolbar drives the theme. Copy that approach if you want the same behavior in yours. ## Theme-Aware Design ### Color Adaptation Colors are the primary difference between themes: * **Dark Mode**: Uses lighter text on darker backgrounds * **Light Mode**: Uses darker text on lighter backgrounds All semantic colors (success, warning, error, info) have theme-appropriate variants to ensure proper contrast and readability. ### Automatic Adjustments When switching themes, the following automatically update: * **Text Colors**: Primary, secondary, and muted text colors adjust for readability * **Background Colors**: Container, card, and input backgrounds adapt * **Border Colors**: Borders become lighter or darker based on the theme * **Shadows**: Box shadows are optimized for each theme * **Status Colors**: Success, warning, error, and info colors maintain proper contrast ## Developer Experience ### No Additional Code Required Beyond importing the stylesheet once and deciding when `data-theme` flips, you don’t write any theme-switching logic. Simply use the provided components and CSS variables: ```tsx // This automatically works in both themes

My Component

``` ### Theme-Safe Custom Styles When adding custom styles, use CSS variables to ensure theme compatibility: ```tsx // ✅ Good - Uses CSS variables
Theme-safe content
// ❌ Avoid - Hard-coded colors
Only works in dark mode
``` ## Testing Your Stories ### Always Test Both Themes To ensure your stories look great in all contexts: 1. **Start in Dark Mode**: Verify your story looks correct in the default theme 2. **Switch to Light Mode**: Set `data-theme="light"` and check that all elements remain readable and visually appealing 3. **Check Color Contrast**: Ensure text is legible against backgrounds in both themes 4. **Verify Interactive States**: Hover, focus, and active states should work in both themes ### Common Issues and Solutions #### Issue: Poor Contrast in One Theme **Problem**: Text is hard to read in light mode but fine in dark mode. **Solution**: Use semantic text color variables instead of hard-coded values: ```tsx // Instead of:

Text

// Use:

Text

``` #### Issue: Borders Not Visible **Problem**: Borders disappear or become too prominent when switching themes. **Solution**: Use border color variables: ```tsx // Instead of:
Content
// Use:
Content
``` #### Issue: Custom Colors Don’t Adapt **Problem**: Brand or custom colors don’t change with the theme. **Solution**: Define theme-specific versions of your custom colors or use the primary color variable: ```tsx // For brand colors that should adapt: ``` ## Best Practices 1. **Always Use Variables**: Prefer CSS variables over hard-coded color values 2. **Test Both Themes**: Verify your stories in dark and light modes before committing 3. **Semantic Colors**: Use semantic variables (`--color-text`, `--color-background`) instead of specific shades 4. **Document Theme Considerations**: If a component has theme-specific behavior, document it 5. **Consistent Patterns**: Follow the patterns established by existing story components ## Implementation Details The theme system is implemented using: * A stylesheet published inside `@cratis/arc.react`, reached through the `@cratis/arc.react/stories/styles.css` export and imported once from your Storybook preview * CSS custom properties declared on `:root` for the dark palette and on `[data-theme='light']` for the light one, which is why the attribute can sit on any ancestor and why your own CSS can override any of them * Nothing else - no runtime, no theme provider, no build step Developers using the story components don’t need to understand these implementation details—the system works transparently. # Storybook > Browse Arc's React building blocks — CommandForm fields, query and observable-query helpers, and the story components — live in an interactive Storybook. [Cratis Arc React Storybook](/storybook-arc/index.html?docsSiteTheme=dark) # Vite Configuration > Configure the Vite dev-server proxy so Arc's WebSocket observable-query hub and other endpoints reach a backend on another port. When you use Vite’s built-in dev server to proxy API requests to a backend running on a different port, WebSocket connections require explicit opt-in. ## WebSocket proxy for the observable query hub Vite’s proxy is built on [http-proxy](https://github.com/http-party/node-http-proxy). By default it only proxies regular HTTP requests. To also forward the WebSocket upgrade handshake that the [observable query hub](/arc/frontend/react/queries/observable-query-multiplexing/) requires, you must set `ws: true` on every proxy rule whose target serves WebSocket connections. The hub endpoint is `/.cratis/queries/ws`. Without `ws: true` on the `/.cratis` proxy rule, the browser issues a WebSocket upgrade to the Vite dev server, but Vite returns a plain HTTP response and the connection fails. Observable queries silently receive no data and repeatedly attempt to reconnect. ### Correct configuration vite.config.ts ```ts export default defineConfig({ server: { proxy: { '/.cratis': { target: 'http://localhost:5001', ws: true, // required for WebSocket upgrade }, }, }, }); ``` Compare with a common incorrect configuration that handles only HTTP: ```ts // ❌ Missing ws: true — WebSocket connections fail silently '/.cratis': { target: 'http://localhost:5001', }, ``` ### Why it is easy to miss Most proxy rules are added to forward `fetch` and `XHR` traffic. Those work without `ws: true` because they are ordinary HTTP requests. Observable queries are the only part of Arc that upgrade to a WebSocket, so the missing flag goes unnoticed until you observe that live data is never delivered to the frontend. Vite docs: [Server options — proxy](https://vite.dev/config/server-options.html#server-proxy). ### Checking a full proxy block A typical Vite setup that exposes both the REST API and the observable query hub looks like this: ```ts server: { proxy: { '/api': { target: 'http://localhost:5001', ws: true, // required if any /api route also uses WebSocket }, '/.cratis': { target: 'http://localhost:5001', ws: true, // required for /.cratis/queries/ws }, }, }, ``` Neither rule needs `changeOrigin: true` when the backend is on the same machine. # Glossary > One precise line per Arc term, stated once so it means the same thing in every backend language and on the frontend. Cratis Arc is an opinionated CQRS application framework with commands, queries, validation, authorization and generated TypeScript proxies for ASP.NET Core, Spring Boot and the TypeScript source preview. Arc is implemented for more than one backend language, and the same idea sometimes carries a different spelling in each. This page defines the idea. Where the spellings differ it names them, so a term you meet in one implementation is recognizable in another. Terms that belong to one implementation only live in that implementation’s own glossary: [C#](/arc/backend/csharp/) and [Kotlin and Java](/arc/backend/kotlin/reference/glossary/). The [TypeScript capability reference](/arc/backend/typescript/reference/capabilities/) covers the source preview’s implemented and missing features. ## Write side * **Command** — a declared intention to change something. A type marked `[Command]` (C#), `@Command` (JVM), or `@command()` (TypeScript) whose properties are the inputs. Arc validates and authorizes it before it runs. * **Handler** — the method on the command that decides what happens. `Handle()` in C#, `handle` on the JVM, `handle()` in TypeScript. It receives its dependencies as parameters rather than reaching for them. * **Provide** — an optional method that runs after validation and authorization and before the handler, to fetch or compute what the decision needs. Its return value is passed into the handler, which keeps the decision a function of its arguments. It can also short-circuit, rejecting the command before it is handled. * **Command key** — the property that says *which* thing a command acts on. `[Key]` / `ICanProvideKeyForCommand` in C#, `@CommandKey` / `CommandKeyProvider` on the JVM, or `@key()` / `getKey()` (or a registered resolver) in TypeScript. A command that creates something new usually has no key. Mark only one property: C# uses the first `[Key]` property it finds rather than rejecting a second. * **Command result** — the envelope every command returns: whether it succeeded, the response value if any, validation results, and whether the caller was authorized. The frontend reads the result through its generated proxy; check each backend’s response and metadata coverage. * **Command filter** — a cross-cutting rule applied around command execution. * **Execution scope** — a lifetime concern bracketing a whole command: it begins before filters and the handler and completes afterwards whatever they decide, which is how a transaction wraps a command rather than living inside it. In C#, a command rejected by its up-front role check, or sent only to be validated, never begins its execution scopes. ## Read side * **Read model** — a type marked `[ReadModel]` (C#), `@ReadModel` (JVM), or `@readModel()` (TypeScript) holding state shaped for a particular screen or question, rather than for storage. * **Query** — a method on a read model that returns it. Static in C#, a `@JvmStatic` companion function or Java static method on the JVM, or a static `@query()` method in TypeScript. * **One-shot query** — a query that answers once and completes. * **Observable query** — a query whose return type keeps producing. The return type is the whole difference between load-once and stay-live: `ISubject` in C#, a `Flow`, `StateFlow` or `Flow.Publisher` on the JVM, or an `ObservableSource` in TypeScript. * **Change stream** — the delta form of an observable query result: what was added, replaced or removed, rather than the whole collection again. * **Paging and sorting** — request-level concerns Arc applies around a query, so a query does not implement them itself. ## Validation and access * **Validation result** — a rejection with a message, the members it concerns and a severity. Usually produced by validators before the handler runs; a handler can also return one. Either way it is surfaced in the command result so the frontend can show it against the right field. * **Concept** — a wrapper giving a domain value its own type rather than passing a bare primitive: `ConceptAs` on C#, JVM, and TypeScript. TypeScript concept classes also declare a runtime `valueType`. Validation attached to a concept travels with the value everywhere it appears. * **Identity details** — the application’s own answer to “who is this user”, built from the authenticated principal when the frontend asks for identity (for example, `/.cratis/me` on C#), not on every command or query request. * **Authorization metadata** — the roles or policy a command or query requires. Declared on the type and overridable per operation, so a class can be closed by default and one operation opened. * **Tenant resolution** — deciding which tenant a request belongs to, before any storage is selected. Selecting a tenant is not the same as proving the caller may access it. ## The boundary * **Proxy generation** — producing the TypeScript client from the backend’s own declarations through a configured generator, so regenerating and type-checking can detect structural drift. TypeScript’s source preview runs a source-based generator explicitly; the emitted client targets the shared frontend runtime. * **Generated proxy** — the TypeScript command or query the frontend imports. It is build output, never edited by hand. * **Artifact** — anything Arc discovers and exposes: a command, a query, a read model, a validation rule, an identity-details provider. * **Introspection endpoints** — the runtime description of the registered artifacts, served under `/.cratis/`. ## Optional integrations * **Chronicle integration** — the optional event-sourced write path. A handler returns events instead of writing directly, and Arc appends them. Arc does not require it. * **Current-state persistence** — the ordinary alternative: the handler writes to a database through an injected service or repository. # Arc for Go > Use Cratis Arc's foundation contracts from idiomatic, dependency-free Go packages. Arc for Go is the Go port of Cratis Arc’s HTTP CQRS framework. Without these foundation packages, each application must recreate Arc’s routes, scalar codecs and result envelopes; with them, you share those contracts explicitly. **Start with [your first running application](/arc/go/backend/go/core/getting-started/).** Go 1.26 or later is required. This experimental port supports backend pipelines, snapshot and observable HTTP hosting, identity, and protected discovery. Typed Go adapters and bounded TypeScript model/command/snapshot/observable-query proxies are available, alongside optional Chronicle integration and MongoDB snapshots and source-only watches. The generated observable Node case exercises default WebSocket hub/Delta behavior with an independent collection consumer. A separate bounded mounted React lane executes real generated hook reconstruction, cache reuse and joined cleanup with explicit WebSocket/Delta providers. This is not browser/DOM/StrictMode or full Arc-wrapper parity; suspense, reconnect, server paging, setter correctness and 30-second expiry remain unverified. MongoDB watches require explicit close/join/reopen after terminal loss; see the [Partial provider profiles and limits](/arc/go/parity/). Opaque provider-source generation, Chronicle watches, and OpenAPI remain unsupported. Constructing metadata or a result alone does not execute business code; the application builder compiles the HTTP endpoints. The [paired snapshot HTTP checkpoint](/arc/go/parity/#paired-snapshot-http-checkpoint) compares 36 GET/QUERY requests against pinned C# Arc: 34 strict matches and two exact ordinary-list paging-metadata allowances. This remains Partial; commands, authentication, HEAD, providers, streams and browsers are outside that pair. ## Inspect a command route and result This is the body of the [compiled Example](https://github.com/Cratis/Arc.Go/blob/main/example_test.go). It uses packages `metadata`, `concepts` and `commands` under `github.com/cratis/arc.go`, plus `fmt`. ```go command := metadata.Command{Type: metadata.TypeName{ Namespace: "Tasks.Registration", Name: "RegisterTask", }} catalog := metadata.Catalog{ Version: metadata.Version, Commands: []metadata.Command{command}, } routes, err := metadata.Resolve(catalog, metadata.DefaultOptions()) if err != nil { fmt.Println(err) return } id, err := concepts.ParseUUID("00112233-4455-4677-8899-aabbccddeeff") if err != nil { fmt.Println(err) return } result := commands.WithResponse(id, struct { ID string `json:"id"` }{ID: "a1"}) response, present := result.Response() fmt.Println(routes[0].Method, routes[0].Path) fmt.Println(result.StatusCode(), response.ID, present) ``` Output: ```text POST /api/tasks/registration/register-task 200 a1 true ``` ## Choose a contract * [Concepts and JSON values](/arc/go/backend/go/concepts/): UUIDs, dates, ticks and missing/null input. * [Command results](/arc/go/backend/go/commands/results/): success, rejection and response presence. * [Query results](/arc/go/backend/go/queries/results/): readiness, paging and change-set values. * [Validation findings](/arc/go/backend/go/validation/): severities and machine-readable reasons. * [Routes and stable identities](/arc/go/backend/go/configuration/routing/): explicit metadata and collision diagnostics. * [Generate adapters and proxies](/arc/go/backend/go/generation/): typed Go wiring and bounded TypeScript publication. * [Parity ledger](/arc/go/parity/): pinned sources, executable evidence and deliberate differences. ## Publication After the first tagged release, use `go get github.com/cratis/arc.go@latest`. The planned release series is v0.x; experimental APIs may change between minor releases. See the [contribution guide](https://github.com/Cratis/Arc.Go/blob/main/CONTRIBUTING.md) and [release policy](/arc/go/releases/). Foundation fixtures and Go-owned hosting tests do not certify full-product, paired .NET or browser parity. Central documentation-site integration remains separate. # Authentication handlers > Compose trusted credential adapters without treating display cookies or forwarded headers as proof of identity. Authentication decides who called; [authorization](/arc/go/backend/go/authorization/) decides what they may do. Arc.Go applies an ordered handler chain at HTTP ingress, not a built-in token verifier. You supply verified adapters as ordinary values or `HandlerFunc` closures. ## Compose a trusted chain This excerpt assumes imports for `authentication`, `identity`, `context` and `net/http`, plus a request already authenticated by your host: ```go chain, err := authentication.New(authentication.HostPrincipal()) ``` Inspect `err`, then call `chain.Authenticate(ctx, request)`. `HostPrincipal` reads only `identity.PrincipalFrom(ctx)`. Installing a principal is a trusted operation: `identity.NewPrincipal` does not check credentials. Never construct it from an unverified token payload, display cookie or caller-authored identity header. | Result | Chain action | | --- | --- | | `Anonymous()` or zero | Continue to the next handler | | `Authenticated(principal)` | Stop; anonymous principals are rejected at construction | | `Failed(reason)` | Stop; later handlers cannot rescue rejected credentials | | Callback error | Stop with the original error, not credential rejection | `New` rejects nil and typed-nil handlers, copies the registration slice and borrows the handlers. Shared handlers must support concurrent calls. Zero `Chain` is empty. Chain exhaustion remains anonymous. Supplied context is installed on a request clone; context, headers and URL are isolated from the caller. The body is shared, not cloned: handlers must not read `Body`. Other request data is not guaranteed isolated, and request data is borrowed only for the call. Cancellation is checked before and after callbacks; callbacks must cooperate. ## Handle failures at ingress `Result.Principal()` returns the trusted snapshot and presence; `Result.Failure()` returns immutable diagnostic data or nil. A `Failure` wraps `ErrFailed`; `Reason()` is local diagnostics, while its error text is generic. Other categories are `ErrInvalidHandler`, `ErrInvalidPrincipal` and `ErrInvalidRequest`. Application hosting maps explicit credential failure to 401 even for a public operation. An exhausted anonymous chain proceeds to authorization; ordinary denial is 403, including anonymous role denial. This differs from native C# Core middleware’s blanket credential requirement and anonymous failure exemption. The standalone chain writes no response; the application owns that boundary. Callback errors are server faults and log at Error. Never include or wrap token text, credentials or header values in those errors. Use `Failed(reason)` for credential rejection; ingress never logs the reason. Client-attributable tenant selection errors log at Warn and return malformedRequest validation findings. There is no automatic cookie, Basic or JWT adapter. Explicitly register `authentication.MicrosoftIdentityPlatform` with `MicrosoftIdentityOptions` to accept unsigned forwarded headers only when `TrustForwardedIdentityHeaders` is true. Repeated headers and oversized/malformed principals are rejected. When trust is off, headers are ignored and requests remain anonymous. Set the optional `MicrosoftIdentityOptions.Logger` to receive a Warn diagnostic on the first such request across all adapter instances in the process. No header values are logged; nil disables diagnostics. Only trust forwarded identity when authenticated ingress strips caller-supplied headers, replaces them, and prevents direct access to the backend. See the [parity ledger](/arc/go/parity/) for the source-derived ingress differences. # Authorization declarations and policies > Compile operation declarations and evaluate trusted identity before invoking application dependencies. A validation warning must never bypass a permission check. Arc.Go separates metadata preparation, role checks and policy evaluation so you can gate an operation before constructing its validators or handler. Both command/query pipelines and their HTTP endpoints enforce this ordering. ## Declare operation requirements Use keyed descriptor literals. The optional authorization fields extend metadata format 1; additions to public structs can break unkeyed Go literals during v0. | Declaration | Meaning | | --- | --- | | Nil | Undeclared; use application fallback, or public when no fallback exists | | `&metadata.Authorization{}` | Authentication required | | `AllowAnonymous: true` | Public; suppress fallback | | Requirements | AND across requirements; OR within each role list | A query method’s `Authorization` replaces `ReadModelAuthorization`, rather than adding to it. If both are nil, fallback applies. Commands use their own declaration then fallback. Anonymous plus requirements is contradictory. Roles are exact, case-sensitive strings; do not put comma-separated role syntax in a Go role list. ## Register and compile direct policies Policies are borrowed concurrently-callable values or `PolicyFunc` closures, not service registrations. This excerpt assumes a zero `authorization.Registry` named `registry` and imports `authorization` and `context`: ```go err := registry.Register("owner", authorization.PolicyFunc( func(_ context.Context, value authorization.Context) (authorization.Decision, error) { if value.Principal.ID() == value.Resource { return authorization.Allow(), nil } return authorization.Deny("not owner"), nil }), authorization.PolicyOptions{}) ``` Inspect `err`, then call `registry.Build(catalog, options)`. Build validates all supplied levels, even overridden read-model declarations, without calling any policy. Queries sharing a read-model identity must supply identical `ReadModelAuthorization` content, including declaration presence and requirement order, even when a method overrides it. Mismatches fail with `ErrInvalidConfiguration`. Build copies declarations and fallback. A successful build freezes the single-owner registry; a failed build remains editable. Evaluators are immutable; shared policies must independently support concurrent use. Names are nonempty exact strings with no surrounding whitespace or control characters. Empty roles, unknown/duplicate policies, invalid/duplicate artifact identities, contradictory declarations and unsupported catalog versions fail. Every named authentication-scheme declaration is rejected, matching native Core. Root Build compiles one evaluator over the combined command/query catalog. `RegisterPolicy` supports lazy scoped factories and declared dependency keys, checked during pipeline composition without activation. Use explicit constructor dependencies or closures; do not capture request-bound identity or tenants in a shared policy. ## Prepare, evaluate and recheck `evaluator.Prepare(ctx, target)` captures principal and tenant, including presence, without authorizing or constructing anything. `prepared.Evaluate(ctx, resource)` checks metadata continuity, then all role requirements, then policies in declaration order. It checks metadata after each callback. `prepared.Check(ctx)` must also run after other application callbacks and immediately before handler invocation. A zero Prepared fails; it is never an authorization token. Policies receive `Context` with Principal, Tenant, Target, borrowed Resource and ReceivedAt. Do not mutate or retain Resource. Decisions are never cached across operations or security identities. An anonymous caller can evaluate only when every requirement is policy-only and every policy explicitly opts in with `EvaluatesAnonymous: true`. Roles and empty authentication requirements prevent that exception. Guest policies receive an empty Principal, never the anonymous caller’s ID, name, roles or claims. Continuity checks still compare the original caller snapshot and presence, so replacing an anonymous caller or authenticating during a callback invalidates the prepared operation. `identity.System` is not privileged. A zero Decision denies. `Deny(reason)` retains local diagnostics; `Decision.Err()` wraps `ErrDenied` without exposing the reason in text. Policy errors return a denied decision plus the original error. Cancellation remains cancellation. Unexpected policy panics are captured at the pipeline callback boundary and redacted. Use `errors.Is` for `ErrInvalidConfiguration`, `ErrDuplicate`, `ErrFrozen`, `ErrUnknownPolicy`, `ErrUnsupportedScheme`, `ErrUnknownTarget`, `ErrIdentityChanged` and `ErrNotPrepared`; `ConfigurationError` supplies target and policy identities. No claims or request values appear in configuration errors. An unknown runtime target returns `ErrUnknownTarget` alone, not a configuration error, and its error text does not include the requested identity. Explicit anonymous bypasses declaration policies, not configured [tenant membership](/arc/go/backend/go/tenancy/). The evaluator does not create or verify service scopes. Discovery uses its own [exposure policy](/arc/go/backend/go/introspection/), not operation fallback declarations. The [parity ledger](/arc/go/parity/) records remaining scheme and generation limitations. # Chronicle integration > Return events from Go commands, inject keyed read models, and run aggregate decisions in one command-owned transaction. An Arc command can return a registered event instead of calling the event store. The optional `github.com/cratis/arc.go/integrations/chronicle` module stages those events and commits them only after the command and its nested commands succeed. Arc’s root module still works without Chronicle. **Source preview:** the integration has no tagged release. It requires Go 1.26 or later and independently fetchable Arc/Chronicle dependencies. The kernel contract tests target `cratis/chronicle:19.29.4-development`. Do not treat this slice as complete C# integration parity; the [parity ledger](/arc/go/parity/) records the boundaries. Arc’s [observable sources](/arc/go/backend/go/queries/observable-queries/) can consume an application-owned feed, but this module does not provide Chronicle read-model watches. Do not simulate authoritative watches from command append notifications; a future SDK watch adapter must own cancellation/join and store/namespace/key selection. MongoDB watches are also separate work. ## Run the task-board example From a repository checkout, start a development kernel and run the example in the nested module: ```bash docker run -d --rm --name arc-go-chronicle \ -p 127.0.0.1:35000:35000 cratis/chronicle:19.29.4-development cd integrations/chronicle export GOWORK=off GOTOOLCHAIN=local export CHRONICLE_INTEGRATION_CONNECTION_STRING=chronicle://localhost:35000 go run ./examples/taskboard ``` The example uses development credentials and certificate handling. Replace both before deployment. If the host port is occupied, choose a free host port and change the connection string; do not stop someone else’s kernel. In another terminal, create a task: ```bash curl -s http://localhost:8080/tasks/create \ -H 'Content-Type: application/json' -d '{"title":"Write the chapter"}' ``` The successful command envelope contains a generated source ID in `response`. Read its passive projection with `GET /tasks/by-id?id=`. The model is registered independently with Chronicle’s projection and Arc’s query surface. The example maps the selected tenant to the same namespace for both writes and reads; namespace selection alone grants no tenant membership. Stop the example with Ctrl-C. Stop your development container with `docker stop arc-go-chronicle` when finished. ## One model, two frameworks You do not need a projection DTO and a separate query DTO. The [shared-model example](https://github.com/Cratis/Arc.Go/blob/develop/integrations/chronicle/examples/sharedmodel/model.go) uses one `Inventory` struct with explicit JSON names, an unnamed `ByID` namespace method and a Fundamentals `ProductName` concept. Its declaration includes: ```go type Inventory struct { ID string `json:"id" arc:"identity" chronicle:"key" example:"shared"` ProductName ProductName `json:"product_name" chronicle:"set(@registered,from=displayName)"` URLValue string `json:"URL_value"` Note *string `json:"note" chronicle:"set(@registered);clear(@cleared)"` State string `json:"state" chronicle:"value(@registered,value=\"available\")"` Transient string `json:"-"` } ``` This is a declaration excerpt; `ProductName`, events and registration helpers live in the example. `RegisterChronicle` registers events, the read model and a model-bound projection, binding the two event aliases explicitly. `RegisterArc` separately registers the same type and adapts `Inventory{}.ByID` through `queries.Function` on an Arc builder. The query takes a typed reader as a normal collaborator; composition selects the store and tenant namespace. There is no shared registry, `init` registration or framework state on the model. Chronicle maps `displayName` to `product_name`, automatically maps `URL_value`, and clears the nullable note after `NoteCleared`. Arc returns those exact names; a nil note is absent from the payload. Arc identity and Chronicle key metadata remain independent. Follow all seven [shared read-model rules](/arc/go/backend/go/queries/model-bound/#shared-arc-and-chronicle-rules). Run the offline registration/query/HTTP tests with `go test ./examples/sharedmodel` from the integration module. With the development kernel configured as above, run the materialization test: ```bash go test -tags=integration -count=1 -timeout=2m \ -run TestOneModelProjectsAndServesArcNamespaceQuery ./internal/integration ``` It appends events, waits for the active projection, then checks Arc’s direct query and HTTP payload in two namespaces using the same key. Successful append alone is not proof that an eventually consistent projection is ready. ## Return events and semantic identities This excerpt is the command from the [task-board example](https://github.com/Cratis/Arc.Go/blob/develop/integrations/chronicle/examples/taskboard/main.go); a source comparison keeps it in sync: ```go func (c CreateTask) Handle(context.Context) (commands.Outcome[integration.EventSourceID], error) { id, err := integration.NewEventSourceID() if err != nil { return commands.Outcome[integration.EventSourceID]{}, err } return commands.Respond(id, TaskCreated(c)), nil } ``` The selected semantic `EventSourceID` response retargets bare returned events and remains the client response. A raw UUID response does **not** retarget events. `EventForSource` retains its own destination. Retargeting never reloads a previously injected model or aggregate. Source selection is deterministic: `EventSourceIDProvider`, configured selector, first semantic source-ID or `arc:"key"` field, then a generated ID if no declaration exists. A declared nil or unconvertible key is Unspecified, not permission to generate another ID. Ordinary `ID` field names have no command-key meaning. The SDK adapter also recognizes Chronicle’s exact `events.SourceID`. Use `Events(...)` for explicit event collections and `EventsWithScopes(...)` for ordered entries plus checks. Ordinary slices are not guessed to be events. Typed nil registered events, unregistered wrapped events, invalid metadata and incompatible scopes fail rather than disappearing. A completely empty batch has no append work; a scope-only batch executes its checks. Input values must not be mutated during staging; the SDK snapshots them before returning. `ConfigureCommand[C]` supplies route, sequence, source selector, compliance subject and concurrency flags. Configure it before Install; you may register commands after Install, but Build rejects any configured `C` that is not exactly registered. `T` and `*T` are distinct command types. `Subject` is a compliance identity, never a principal. Explicit Go wrapper options can supply per-entry route, subject and causation in addition to the C#-style target, tags and occurrence. ## Understand commitment and recovery Only `invocation.Pipeline()` joins a command transaction. An independently invoked root pipeline gets a separate owner even inside another command. There is no ambient transaction lookup or second integration-owned event buffer. Ordinary completion runs before the terminal commit. Failed input, handlers, nested commands or ordinary completion roll back open staged work. Store constraints become `constraintViolation` findings; concurrency conflicts become `concurrencyViolation` findings with source, expected position and actual position. Explicit JSON member names are preserved. Store rejection is not weakened by input severity allowances. Inspect `Result.Completion()` and `commands.CompletionError` on the server. A failed command may have committed earlier work. Unknown outcomes fail the envelope and remove its response but do not prove that persistence failed. There is no automatic commit, handler, aggregate or reactor-command retry; reconcile or use application idempotency before resubmitting. Immediate SDK writes remain immediate. After authorization, the adapter establishes readiness and subscribes to the selected cached sequence. For an attributable append, an ignored rejection fails the command and rolls back deferred work; an earlier confirmed immediate write cannot be undone. The subscription ends before deferred or early aggregate completion. Raw low-level sequence handles, other sequences, and errors before dispatch are outside this observation window: always inspect direct SDK append results yourself. Configure append attribution once when constructing the shared Chronicle client: ```go client, err := chronicle.NewClient( chronicle.WithRegistry(registry), chronicle.WithAppendOriginResolver(sdk.ResolveAppendOrigin), ) ``` This is a construction excerpt; configure your connection and authentication too. `sdk.New` borrows the already-built client and cannot retrofit its frozen resolver option. Without this option, arbitrary handler appends are **not attributed**. The task-board example includes it. Each root execution allocates a fresh, immutable local origin, even without staged work. After mandatory authorization, every executing frame publishes it in Arc’s command snapshot. Joined children and grandchildren share the root origin; an independent root Execute selects a new origin even when its context inherits the same correlation or an explicit SDK origin. Advisory Validate publishes no origin: its snapshot resolves to zero and masks inherited attribution. Notifications must match the exact nonzero origin and the selected store, namespace and sequence. Correlation is diagnostic only, so concurrent same-correlation commands remain separate. SDK-owned unit commits use their own origin and bypass the resolver; they are not counted as immediate handler appends. Resolver errors or panics fail before dispatch without fallback. Origins are local attribution metadata, not authorization credentials or persistence guarantees. Coverage requires the supplied callback context and this configured client. Appends that discard that context, use another client or raw low-level handles, target other coordinates, or outlive the command’s subscription are outside the mechanism. Direct SDK appends by application filters or validators in operation commands before terminal `Begin` are also unattributed and outside the immediate-append mechanism. Zero-origin notifications are ignored. Pre-dispatch failures do not emit notifications. Always inspect every direct append result, including rejected and uncertain outcomes; immediate persistence cannot be rolled back by a later command failure. ## Compensate operations against Chronicle outcomes A booking command often has to do two things: record `SeatBooked` in Chronicle and reserve the seat in an external system. If the reservation succeeds and the Chronicle commit is then rejected, you need to release the seat. If the events did persist, releasing it would be wrong. Arc’s [command operations](/arc/go/backend/go/commands/operations/) handle this split. Chronicle reports what actually persisted, and Arc decides whether compensation is safe. The installed integration fills Arc’s sole operation-classified terminal slot, so operation commands run against Chronicle with no further registration: ```go type reserveSeat struct{ BookingID, SeatID string } func (reserveSeat) CommandOperation() {} func (o reserveSeat) Execute(ctx context.Context, seats SeatService) error { return seats.Reserve(ctx, o.BookingID, o.SeatID) } func (o reserveSeat) Compensate(ctx context.Context, seats SeatService, _ commands.OperationFailure) error { return seats.Release(ctx, o.BookingID) } err := commands.RegisterOperation[reserveSeat, SeatService](builder.Commands(), func(context.Context, *execution.Scope) (SeatService, error) { return seats, nil }) err = commands.Register[BookSeat](builder, commands.Handle(func(c BookSeat, _ context.Context) (commands.Outcome[commands.NoResponse], error) { return commands.Effects[commands.NoResponse](SeatBooked{SeatID: c.SeatID}, reserveSeat{c.BookingID, c.SeatID}), nil }), commands.WithOperations[BookSeat]()) ``` This excerpt is checked against the compiled `integrations/chronicle/operation_example_test.go` example: install the integration first, check each error, and declare `SeatService`, `BookSeat` and `SeatBooked` yourself. Handle only declares work. Arc stages the returned events, enters the operations in order, then runs the terminal Chronicle commit. Recovery runs only after that commit, in reverse order. The completion report decides recovery: | Completion | Typical cause | Recovery | | --- | --- | --- | | `NoPersistedWork` | An operation failed and nothing was staged or appended | Compensators run | | `NotCommitted` | The deferred commit was rejected, for example by a unique constraint | Compensators run | | `Committed` | An operation’s attributed immediate append was confirmed before it failed | `RecoverySuppressed`; nothing is reversed | | `OutcomeUnknown` | An immediate append or the commit lost its acknowledgement | `RecoveryIndeterminate`; reconcile first | | `MixedCommit` | Confirmed immediate writes plus rolled-back deferred work | `RecoveryIndeterminate`; reconcile first | Inspect `result.Recovery()` and `result.OperationOutcomes()` on the server. They are backend-only and never reach the HTTP envelope. Operation commands begin the Chronicle transaction after authorization, filters and validation. The integration records the authorized observation request in its filter. The terminal `Begin` then opens the transaction, publishes the append origin and subscribes, all before `Provide`, `Handle` or any operation runs. The integration itself never persists during filters or validation, and a validation failure leaves no subscription behind. Application filters and validators can still append directly through a retained SDK store; appends before `Begin` have no command origin and are outside immediate-append observation. Before the first operation is entered, Arc asks the integration for a read-only snapshot of persistence facts. If a handler already caused a confirmed, unknown or mixed immediate append, Arc refuses to enter any operation and the command fails with `commands.ErrInvalidOperation`. A rejected handler append is `NotCommitted`, not an uncertain outcome. Even though its rejection poisons the transaction, Arc may still enter the returned operations, then roll back deferred work and compensate those operations against the known final facts. Inspect direct append results and return the failure from the handler to avoid that execute/compensate cycle. The integration fails closed when missing observation is detectable. Without an `AppendObserver`, such as a custom `Options` without `Appends`, operation commands fail with `ErrUnsupported` before any operation is entered; ordinary commands are unaffected. The SDK adapter always installs the observer, but attribution still depends on configuring `sdk.ResolveAppendOrigin` when you construct the client, before `Capture` in shared-provider setups. Arc cannot detect a client built without it, so an immediate append from such a client looks like no work and can be compensated wrongly. Explicit aggregate commits are refused in operation commands `AggregateRoot.Commit` returns `commands.ErrInvalidOperation` in an operation command, before any commit RPC. The refusal poisons the shared owner, so the command fails, returns no response, enters no operation and persists nothing, even if the handler ignores the error. Return the events and let the terminal step commit them. Ordinary commands keep explicit commit. Compensation is a new write, never an unappend. A compensator receives its dependency bundle from planning time. Borrowed SDK facades in that bundle, such as the `*chronicle.EventStore` or an event sequence, stay usable while the client is open. The command’s transaction, unit of work and aggregate roots are already completed and must not be reused. Check each compensating append’s own result; the original completion report does not describe it. Arc never retries an operation, a compensator or a commit. ## Inject keyed read models Register a model with Chronicle, bind its typed declaration with `sdk.BindReadModel`, then install the integration. The frozen selected-store catalog must identify its projection or reducer. An Arc query declaration alone does not grant Chronicle ownership. `sdk.BindExternalReadModel` is an explicit assertion of an external producer. Inside a handwritten `commands.Invoke` or `commands.Prepare` adapter, use `commands.RequireReadModel[M](ctx, invocation)`, `ReadModelOrNil[M]`, or presence-bearing `ResolveReadModel[M]`. Ordinary domain methods can keep their model parameter. The current generator does not yet emit these integration-specific parameter adapters. Missing/blank keys fail even for optional injection. A valid key with no model gives nil for optional reads and `dependencyUnavailable` for required reads. A present zero-valued model remains present. Successful reads, including absence, are cached per frame, model, key and coordinates; Provide and Handle reuse them, while child frames do not. Failures are never cached as absence. The SDK typed reader owns codecs, collection normalization and its admitted release boundaries. Do not generalize materialized keyed-read behavior to legacy watches, windows or local reducer notifications; their release ownership remains unresolved in Chronicle.Go #35. Snapshot progress is diagnostic, not protected-decision evidence; decide from [Chronicle decision reads](/arc/go/backend/go/chronicle/decision-reads/) instead. A materialized projection can lag. Validation filters may read models without starting transaction participants or running Provide/Handle. ## Make aggregate decisions An aggregate directly embeds `*integration.AggregateRoot`. `DefineAggregate` accepts its constructor and typed `OnAggregateEvent` callbacks. `factory.Get(ctx, invocation)` resolves it by the original command key. Re-resolve for each callback; old root capabilities expire, and aggregate objects must not be used concurrently or retained beyond the command. History is loaded once per root, aggregate type, source and route. Empty history enrolls `NoMatchingEvent`; otherwise the check uses the last **loaded** position, including a real position zero. No later tail read replaces that expectation. `Apply` stages before folding local state; a failing fold poisons the transaction even if its caller ignores the error. `Failed(message, validation.Error)` blocks automatic commitment. Warnings and information do not independently block persistence. The default stream type is the aggregate’s simple type name. `WithAggregateRoute` overrides routing; `ConfigureCommand.Sequence` selects the shared command sequence. History and dispatch must agree. Unknown generations and missing handlers fail explicitly. History is buffered, not a bounded-memory stream. Returned-event snapshots and aggregate history use the selected store’s frozen SDK event descriptors, preserving explicit JSON names, configured naming and field codecs. Each historical event uses its exact registered ID and generation; register that historical shape and its aggregate handler. The adapter does not guess a current shape, use alternate-generation content or migrate locally. A decode failure returns the SDK error and prevents folding any of that history. Explicit `Commit` checks the root execution’s `CheckRecordedFailures` guard before persistence. Already-recorded failures in this frame or an ancestor, including ignored nested authorization, validation and command-lookup failures, prevent commitment. Nested Validate remains advisory during Execute; the guard cannot predict failures that occur after commitment. Explicit `Commit` finalizes the **whole shared owner**, not only this aggregate’s events. Later staging fails without creating a successor. Its positions describe the shared batch and cannot be attributed to one aggregate in a mixed command. Returning `AggregateCommitResult` adopts its diagnostics into the Arc envelope, including warning findings after successful persistence. A later command failure retracts the response but retains the committed completion report; it cannot undo already-persisted events. ## Resolve concurrency for the actual target With no flags or explicit scopes, the SDK’s configured default strategy remains authoritative. Command flags resolve only their selected dimensions through `Sequence.ResolveScope`, separately for each actual target, and cache the result within that frame. Explicit `Resolve` scopes also use the configured policy. Aggregate-loaded scopes take precedence over flag resolution for aggregate mutations. `UpperBound(n)` rejects a newer matching tail; it is **not equality** and can accept absent or lower history. Use `NoMatchingEvent` for an empty-history requirement. Default route sentinels can mean wildcard selection. Incompatible checks sharing a source label fail before append; arbitrary multi-boundary checks for one source are not synthesized. In particular, two aggregate types with different default stream types, or differing routes on one source, cannot share this guarded owner. Use separate source IDs or the same route and expectation. The pinned SDK requires source-bound scope labels to equal the source ID, so source+route labels cannot work around this restriction. C# Chronicle 19.29.1 also rejects conflicting source scopes in strict ordered batches. Its legacy aggregate mutation path can overwrite an earlier source scope; Arc.Go deliberately fails closed instead of discarding a loaded-history check. The SDK’s resolved expectation is opaque. The adapter retains it as an immutable `ProviderResolved` token, including resolved unchecked empty tails, rather than guessing a number or reading the tail again. Tokens cannot cross provider or coordinate boundaries. ## Execute commands from reactors Create `ReactorCommands` with an explicit principal, store and replay policy. Register `sdk.CommandEffects(bridge, reflect.TypeFor[YourCommand]())` through Chronicle’s `RegisterReactorSideEffectHandler` before constructing the client. Bind the bridge to the built Arc application before observers start. Reactor methods can then return commands or collections of commands; the SDK preflights and executes those effects before acknowledgement. The imperative path is `bridge.Execute(ctx, sdk.DeliveryFrom(eventContext, delivery), command)`, called in the reactor handler body, not an After hook. Each command gets a fresh root Arc operation and completion owner. Both Go errors and unsuccessful envelopes fail delivery. Execution stops at the first failed command; earlier commits survive and may repeat on kernel recovery. `LiveOnly` is the recommended explicit automation policy. SDK `OnceOnly` and replay authoring remain visible; replay exclusion is not deduplication. Use the stable delivery ID as an application idempotency input where necessary. Event `CausedBy` metadata never grants Arc roles. Per-event correlation and causation are preserved rather than cached on a batch scope. ## Share a captured client with one provider Keep ordinary `chronicle.NewClient` and constructor/closure wiring when you do not need a service provider. A container is not required by Arc or its Chronicle adapter. When preparation collaborators need the same client that Arc handlers will resolve, capture its identity before building your immutable provider. Prepare it only after all bindings are registered, then create the adapter. The following setup excerpt comes from the compiled [shared-provider example](https://github.com/Cratis/Arc.Go/blob/develop/integrations/chronicle/sdk/composition_example_test.go). It runs inside an error-returning function with a named `err` result and a `ctx`. It assumes a registry with an unclassified event and a `RegisterSeederFactory[*exampleCompositionSeeder]` declaration. That example’s seeder holds a borrowed `*chronicle.Client` and implements `Seed(*seeding.Builder) error`. Imports use `di` for Fundamentals’ `dependencyinjection` package and `container` for its optional default container. Configure connection/authentication options at Capture for runtime use. ```go preparation, err := chronicle.CaptureClient( chronicle.WithRegistry(registry), chronicle.WithAppendOriginResolver(sdk.ResolveAppendOrigin), ) if err != nil { return err } var provider di.Provider defer func() { // No work/observers in this setup example. Runtime users drain Arc first. err = errors.Join(err, preparation.Client().Close()) if provider != nil { err = errors.Join(err, provider.Close(ctx)) } }() var bindings container.Registry if err := di.BindValue(&bindings, preparation.Client()); err != nil { return err } if err := di.BindFunc1(&bindings, di.Singleton, func(_ context.Context, client *chronicle.Client) (*exampleCompositionSeeder, error) { return &exampleCompositionSeeder{client: client}, nil }); err != nil { return err } provider, err = bindings.Build() if err != nil { return err } client, err := services.PrepareClient(ctx, preparation, provider) if err != nil { return err } adapter, err := sdk.New(client, sdk.Config{Store: "example", OwnClient: false}) if err != nil { return err } builder, err := arc.NewBuilder(arc.Options{ScopeFactory: provider, DependencyCatalog: provider}) if err != nil { return err } if err := adapter.Install(builder); err != nil { return err } ``` `BindValue` borrows the exact captured pointer. `PrepareClient` returns that same pointer, and Arc borrows the same provider through both `ScopeFactory` and `DependencyCatalog`. Use singleton lifetimes for client-lifetime collaborators; preparation’s scoped resources are disposed before preparation returns. The example then registers a handwritten typed command, builds and starts Arc without connecting. It prints `prepared without connecting`. Do not combine captured `services.WithServices` with an explicit provider passed to `PrepareClient`: those are conflicting preparation scope configurations. Before or during preparation, actual `sdk.New` returns `ErrNotPrepared` without poisoning the admitted preparation. A concurrent second preparation returns `ErrPreparationInProgress`. Preparation retains one completed outcome and does not rerun its callbacks. Closing the client from a preparation callback or its cleanup returns without self-wait, but publishes `ErrClosed`, not a usable integration. This is not permission to close from arbitrary SDK runtime callbacks. The no-transport fixtures use a real lazy gRPC connection to check zero dials, RPCs, streams and origin resolutions during Capture, Prepare, adapter creation, Install and Build. Preparation is **not** Connect, registration, observer attachment or catch-up. The kernel witness separately verifies shared handler identities, the captured resolver’s actual callback context and exact nonzero command origin. A confirmed immediate append followed by an intentional handler failure retains the original error and a `Committed` completion, with persisted kernel readback; it cannot be rolled back by the later failure. This bounded consumer uses Chronicle SDK `v0.0.0-20261003210034-9e0c8d5ba5f1`, Arc `435a136792b6`, Fundamentals `v0.1.0` and Go 1.26 minimum. The SDK baseline includes fail-closed profile changes beyond two-phase construction. It does not establish classified decision, compliance, watch or replay safety. Legacy watch/window/local-reducer release ownership is still tracked by [Chronicle.Go #35](https://github.com/Cratis/Chronicle.Go/issues/35). ## Wire lifecycle and audit deliberately Construction and Build perform no network I/O. Start Arc admission, bind reactor command bridges, then call `integration.Start(ctx)` for configured `StartupNamespaces`. Begin HTTP serving only after required readiness succeeds. Default clients and supplied providers are borrowed. `OwnClient: true` transfers client closure to an explicit, once-only `integration.Close()` call; it is not a late automatic Arc shutdown hook. For an application-owned client/provider, stop producers first, unregister and join any owned observers, then drain/join Arc with `app.Shutdown(ctx)`. Close the borrowed integration, close/join the client’s runtime, join any outstanding `PrepareClient` call including its resource cleanup, and only then close the provider. Client cancellation alone is not a preparation join. A short Arc shutdown deadline leaves the application Stopping: retain the provider while admitted commands still hold resources, release/join them and call Shutdown again before provider disposal. The composition fixture needs no observer. Provider disposal does not close a `BindValue` client. The isolated no-transport ownership test disposes an idle provider first and then probes real transport to prove the SDK was not closed; this is an ownership test, **not** the normal shutdown order above. Authenticated principal subject and display name map to audit identity. Username requires an explicit trusted actor mapper; anonymous principals become Chronicle’s canonical unknown actor. Tenant Default/NotSet maps to namespace `Default`; other tenants preserve their exact value. Command causes include logical short/full command identity and sequence. Input properties are **not audited by default**. The explicit Audit callback must supply only approved non-PII scalar values; unknown nested objects are never serialized automatically. Reserved names cannot be overwritten. Values are bounded to 1024 characters plus a truncation marker and an 8192-byte raw property-name/value budget. This fail-closed opt-in differs from C#‘s reflected-property auditing. ## Verify independently From `integrations/chronicle`, with a ready development kernel: ```bash export GOWORK=off GOTOOLCHAIN=local export CHRONICLE_INTEGRATION_CONNECTION_STRING=chronicle://localhost:35000 export CHRONICLE_INTEGRATION_IMAGE_DIGEST="$(docker inspect arc-go-chronicle --format '{{.Image}}')" go test -count=1 -timeout=2m ./... go test -tags=integration -count=1 -timeout=2m ./internal/integration ./examples/taskboard python3 scripts/check-boundaries.py ``` Integration-tagged tests fail when the endpoint variable is absent. They exercise HTTP commands, atomic rejection/readback, tenants, shared projection/query models, projection injection, aggregate competition, reactor-returned commands, failed observer partitions, ignored immediate-append rejection, operation recovery for every completion, including a lost acknowledgement, and protected decision reads. Root and tools gates run separately; `./...` does not cross module boundaries. Watches, aggregate snapshots, automatic historical-generation migration, full compliance authoring, generated operation adapters, nested operation workflows and durable operation recovery remain unsupported. None is implied by an ordinary injected model or a successful snapshot test. # Chronicle decision reads > Decide from a Chronicle read model inside a Go command and have a competing append reject the whole commit. A booking command reads a room, sees it is free and appends `RoomBooked`. Between the read and the commit another command books the same room, and both succeed. An ordinary injected read model can’t prevent that, because it is a snapshot: it can lag the event log and it guards nothing. A Chronicle decision read closes that gap. It folds the instance from the event log for this command invocation and enrolls the read with the command’s Chronicle transaction. When another append of an event the read model depends on, for the same key, lands before the commit, Chronicle rejects the whole batch, and Arc reports a concurrency violation instead of persisting a decision made from stale state. This page shows the Chronicle provider. The profile, provenance and verification rules come from the root seam described in [Protected decision reads](/arc/go/backend/go/commands/decision-reads/). ## Enable decisions and read in Provide Install the integration, then certify its client as a decision provider with `sdk.EnableDecisions`. Mark the command with `commands.WithProtectedDecisions` and read through `sdk.ReadDecision` in `Provide`. The excerpt comes from the compiled `ExampleEnableDecisions` in `integrations/chronicle/sdk/decision_reads_example_test.go`, which also registers `RoomBooked`, `Room` and its projection: ```go decisions, err := sdk.EnableDecisions(builder, adapter) if err != nil { return err } err = commands.Register(builder.Commands(), commands.WithProtectedDecisions[BookRoom](), commands.WithoutModelValidation[BookRoom](), commands.WithNoResponse[BookRoom](), commands.Prepare(func(ctx context.Context, inv *commands.Invocation, c BookRoom) (commands.Preparation[*sdk.Decision[Room]], error) { decision, err := sdk.ReadDecision(ctx, inv, decisions, rooms, readmodels.Key(c.ID)) if err != nil { return commands.Preparation[*sdk.Decision[Room]]{}, err } return commands.Provided(decision), nil }, func(_ context.Context, _ *commands.Invocation, c BookRoom, decision *sdk.Decision[Room]) (RoomBooked, error) { // Arc verified the decision before Handle; a competing booking // of this room now rejects the whole commit. if room := decision.Instance(); room.Exists { return RoomBooked{}, validation.Reject(validation.Result{Severity: validation.Error, Message: "Room " + room.Value.ID + " is already booked."}) } return RoomBooked{Guest: c.Guest}, nil })) ``` `*sdk.Decision[M]` implements `commands.DecisionEvidence`, so Arc verifies it after `Provide` and before `Handle`. `Instance()` returns the folded state with explicit absence. The read comes from the event log, not from the materialized projection, so a booking committed a moment ago is already visible. `EnableDecisions` needs the integration installed on the same builder: the installed integration is the completion owner that `Build` requires for a protected command. It performs no I/O. Each call certifies a separate provider identity. ## What happens at commit | Outcome | What you see | | --- | --- | | No competing append | The events commit; `Completion().Disposition` is `Committed`, or `NoPersistedWork` when the command only read. | | Another writer appended an event the read model depends on, for the same key | Chronicle rejects the complete batch, including events for other sources. The result is `NotCommitted` with a `concurrencyViolation` validation result. Read again and resubmit. | | The commit acknowledgement is lost | The result is `OutcomeUnknown`. Arc never retries; reconcile against the event log before resubmitting. | Repeated reads of the same model and key share one fold and one enrollment per command frame, including reads from a command filter and from `Provide`. ## Profiles and refusals `sdk.ReadDecision` behaves by the command’s decision profile: | Profile | Result | | --- | --- | | `WithProtectedDecisions` | Protected, enrolled read. | | `WithUnprotectedDecisions` | Advisory snapshot from the ordinary read-model reader. `IsProtected()` is false and nothing is enrolled. | | Unmarked | Refused with `commands.ErrDecisionProfile`. | Within an active protected invocation, refusals wrap `commands.ErrDecisionRead` and preserve their original causes for `errors.Is` and `errors.As`. A nil context or invocation returns `integration.ErrInvalid` as-is. Before acquisition (no fold or enrollment), the read is refused when: * its arguments are invalid (`integration.ErrInvalid`) or the integration cannot resolve the command’s frozen routing; * the model is classified (`readmodels.WithPII` or any protection metadata), reducer-backed, joined, hierarchical or otherwise not admitted by Chronicle’s decision catalog; `errors.As` exposes `*readmodels.DecisionReadRefused` with its reason; * the model is not registered with the client’s store (`integration.ErrNotRegistered`). After acquisition, checking or enrollment can refuse a foreign, zero or stale token: one issued to another invocation, client, store or namespace, or invalidated by a reconnect or catalog change. These checks run again before `Handle`; the SDK also verifies token lifetime before dispatching the commit. Custom transaction factories can refuse a non-event-log participant at enrollment with `integration.ErrUnsupported`, after the fold. The shipped `sdk.New` adapter always routes commands to the event log. If an application ignores a failed enrollment and still returns events, the integration rolls the transaction back instead of committing unguarded work. ## Validation-only execution `Validate` never runs `Provide`, but command filters run in validation-only execution. A decision read there folds the instance with a separate validation identity and never begins a transaction or enrolls. That read cannot be reused by a later execution of the same command. ## Limits * A token is opaque evidence issued by the Chronicle.Go SDK, not an authenticated server proof. The pinned protocol can’t atomically bind projection definitions or in-place history changes to the fold. * The protected profile admits no model or custom validators, as described for the [root seam](/arc/go/backend/go/commands/decision-reads/#limits). * Classified models are refused rather than released: a session fold can’t establish safe per-subject release. ## Verify against a kernel `internal/integration/decisions_test.go` runs these contracts against `cratis/chronicle:19.29.4-development`: a committed booking and a stale-decision rejection, a competing dependency-event append that rejects a two-source batch, an unrelated-event append to the same source that leaves the decision valid, a lost acknowledgement that stays unknown, a separate validation-only read and the profile, classified-model and unregistered-model refusals. Run it with the commands in [Verify independently](/arc/go/backend/go/chronicle/#verify-independently). # Commands in Go > Put command input and behavior together and execute it through Arc's authorized pipeline. You should not need a second handler type just to act on a command. In Arc.Go, the command owns its `Handle` method. Typed adapters connect that method to authorization, validation, preparation, returned-value handling, and completion without requiring a container. ## Start with a model-bound command Begin with [model-bound authoring](/arc/go/backend/go/commands/model-bound/), then [execute the command](/arc/go/backend/go/commands/command-pipeline/) from your backend. Result-bearing methods use a method expression; a dependency-free `Handle(context.Context) error` supports type-only registration. Your business methods remain directly callable in unit tests. There is no runtime source discovery or reflective invocation. Source directives and arbitrary-signature generated adapters are a separate tooling slice; writing a directive alone does not register a command today. ## Add behavior where it belongs * [Authorization](/arc/go/backend/go/commands/model-bound/authorization/) protects the declaration before ordinary dependencies are constructed. * [Validation](/arc/go/backend/go/commands/command-validation/) rejects input, including model-bound `Validate` methods and registered concept rules. * [Severity filtering](/arc/go/backend/go/commands/validation-severity-filtering/) controls which input-stage findings block, without weakening a command floor. * [Context and keys](/arc/go/backend/go/commands/command-context/) supplies invocation metadata without an ambient dependency resolver. * [Filters](/arc/go/backend/go/commands/command-filters/) contributes ordered verdicts. * [Response value handlers](/arc/go/backend/go/commands/response-value-handlers/) consumes returned effect values; [response examples](/arc/go/backend/go/commands/response-examples/) explains the explicit return grammar. * [Protected decision reads](/arc/go/backend/go/commands/decision-reads/) guards read-model decisions against competing writes through a provider. * [Operations](/arc/go/backend/go/commands/operations/) declares immediate inline work with commit-aware, best-effort compensation. * [Execution scopes](/arc/go/backend/go/commands/command-execution-scopes/) participates in root completion before resources are disposed. ## Know the boundary This is a backend command library, not an HTTP host. It preserves the command result envelope, but does not create endpoints or proxies. Chronicle operation participation and durable delivery remain separate integration slices; the manual flat operation core does not establish provider compatibility. Returning an event-shaped struct does not append it automatically. A failed result is not proof that an external write did not commit. # Command context and keys > Read immutable command snapshots and supply copied context values or an explicit command key. A handler needs to know which command frame it belongs to without borrowing an ambient service provider. `commands.ContextFrom(ctx)` returns a metadata snapshot; `Invoke` and `Prepare` additionally receive an expiring `Invocation` for stage-local facilities. ## Read a snapshot `CommandContext` exposes the descriptor, decoded command, correlation, receipt, principal, tenant, context values, explicit caller allowance, validation-only flag, selected response, and resolved key. Descriptor and membership collections are copied. Command, response, and application objects inside values remain borrowed. Each callback gets a current immutable snapshot. `Invocation.SetValue` changes membership for later callbacks; it does not mutate a snapshot already read from context. Names are case-insensitive. Security metadata and ownership are not writable context entries. ## Supply values before filters `AddContextValuesProvider` registers named lazy providers. They run before authorization and validation, so they are trusted extension work and must not eagerly construct protected dependencies. `NewContextValues` copies map membership and rejects ambiguous case-insensitive names. Later providers overwrite earlier entries. `Entries` returns independent membership, normalized to lower-case names. This is not a deep clone. Shared mutable application objects still require explicit ownership and synchronization. ## Resolve an explicit key once Key precedence is: 1. A provider-written `resolvedKey` entry, including an authoritative empty string. 2. Custom `KeyResolver` registrations, taking the first usable nonempty key. 3. `KeyProvider.GetKey()` on the decoded command. 4. An explicit `WithKey` selector or one `arc:"key"` field. A field named Id or a UUID value is not a key convention. Multiple key tags fail configuration. `arc:"identity"` is read-model metadata and never a command key. The key is provider-neutral; it does not imply Chronicle event-source identity or authorization. ## Keep extension state out of context values An integration can create a private `NewStateKey[T]` and use `RootState`/`SetRootState` for the bound command tree, or `FrameState`/`SetFrameState` for one command’s stages. Root state is not shared by independent executions, even when they borrow the same operation scope. Frame state survives Provide to Handle but is not inherited by children or siblings. Membership is discarded when its lifetime ends. Every access checks callback admission, principal, tenant, correlation, and the current frame. Values are borrowed, not deep-copied or automatically disposed. Guard mutable contents yourself, and recheck `Invocation.Execution().Check(ctx)` before using retained mutation capabilities. Keep keys that identify completion owners private. No initializer runs under a framework state lock. `ParentCommandContext(ctx)` exposes a copied parent metadata snapshot for nested causation, not a parent invocation or owner. Command/value objects inside that snapshot remain borrowed immutable data. ## Resolve command-side read models `RegisterReadModelProvider[M]` binds an exact non-pointer struct type to a command dependency resolver. Register on `builder.Commands()` when composing an Arc application. `DeclaredReadModel` beats `FallbackReadModel`; an application’s `OverrideReadModel` beats both. Duplicate claims at the same level fail, even if another level overrides them. Unlike C# replacement-order behavior, two declared owners never silently replace each other. Generated and handwritten adapters use the same helpers: `ResolveReadModel[M]` retains `Exists`, `RequireReadModel[M]` rejects absence as `dependencyUnavailable`, and `ReadModelOrNil[M]` returns nil for an absent instance. Missing or blank keys are `malformedRequest`, even for optional reads. A present zero-valued model is present; reader and release failures remain errors. This is not a public query invocation or an extra authorization contract. The selected provider owns materialization, release, and caching. Provider-specific frame caches must include the key, coordinates, and read mode when those can vary. Successful reads can be reused between Provide and Handle; transient failures must not become cached absence. Validation filters can use these helpers without starting a transaction or calling Provide/Handle. The executable `ExampleRegisterReadModelProvider` demonstrates the provider-neutral path. The optional [Chronicle integration](/arc/go/backend/go/chronicle/) supplies a separate SDK-backed provider. ## Respect callback lifetimes `Invocation.Scope()` is non-closing and expires at callback return. Its bound `Pipeline()` joins the current command owner; retained, concurrent, changed-security, and ancestor-frame reuse fail. Use a child’s own Invocation for further nested commands. Context values never store a provider, scope, resolver, or execution owner on behalf of the framework. See [execution scopes](/arc/go/backend/go/commands/command-execution-scopes/) for nesting and completion. # Command execution scopes > Enlist root completion participants and safely share command resources with nested execution. Select a response only after the command boundary has finished. An `ExecutionScope` can still reject completion, so the pipeline publishes no failed response even when Handle returned a value. ## Participate in root completion Register participants with `AddExecutionScope(name, factory, keys...)`. The pipeline lazily constructs borrowed participants during root Execute, before membership and declaration authorization. This early phase is trusted extension work, not a place to eagerly construct protected handler dependencies. `Begin(ctx, invocation)` runs in registration order. `Complete(ctx, invocation, result)` runs in reverse order for every entered Begin, including the participant whose Begin failed. A failure does not skip earlier participants’ completion. Later, unentered participants are not completed. Completion receives the already-failed result after cancellation and a live, bounded cleanup context. Its result fragment can only add verdicts and diagnostics; it cannot restore a failed response. Only owned resource disposal follows completion. Validate never constructs participants. ## Distinguish ownership from resources An `execution.Scope` guards security and resource lifetime. A `commands.Execution` guards command owner/frame admission. Neither is a transaction, authorization token, or observed commit fact. The unbound pipeline starts a fresh owner and independent resources even when called from inside Handle. `Invocation.Pipeline()` instead joins the active owner and borrows compatible resources. Participants begin and complete once at the root, not for each child. Child commands get distinct contexts and receipts while preserving correlation. A bound executor rejects a changed correlation; use an independent root execution for a new operation. Ignored child failures remain sticky: discarding a result does not turn the root into success. A child uses its own bound executor for further nesting; reusing an active ancestor executor fails. ## Keep nested calls synchronous The bound executor rejects concurrent admission, expired callbacks, security mismatch, and Execute from a validation-only frame. Do not retain it or leave child work running after the callback returns. The framework joins an admitted child before expiring that callback and reports invalid concurrent lifetime use; it cannot forcibly stop an application callback that ignores cancellation. Queries sharing an explicitly supplied operation scope do not enlist in command completion. These generic participants do not implement Chronicle transactions, operations, compensation, or recovery. Cleanup is not rollback. ## Complete deferred persistence last `AddDeferredCommitParticipant` registers one provider-neutral terminal participant. Its Begin runs before ordinary participants; its Complete runs after all ordinary completion callbacks, regardless of registration order. The result includes ignored nested failures and cancellation. The participant must not commit open work when that result is unsuccessful. New nested commands cannot enter during terminal completion. A second terminal participant is rejected: this is not distributed transaction coordination. Complete returns a `CompletionReport` and an error. Report `NoPersistedWork`, `NotCommitted`, `Committed`, `OutcomeUnknown`, or `MixedCommit` from provider evidence, not merely a nil error or a completed flag. Domain rejection belongs in a validation-bearing error; input severity filtering does not suppress terminal failures. Begin reserves ownership without connection or protected dependency activation. Validate never activates this participant. Before explicit early persistence, `Execution.CheckRecordedFailures(ctx)` checks this frame and its ancestors for already-recorded failures, including ignored nested Execute outcomes. Advisory nested Validate remains advisory. This guard grants no authorization and cannot predict later failures. `commands.ReportCommit` records an early completion. `Result.Completion()` retains the report through later command, resource-disposal, or serialization failures; `CompletionError` also retains the cause for `errors.Is` and `errors.As`. Neither the report nor disposition adds an HTTP envelope field. Failed commands still omit every response, including false, zero, and empty strings. Unknown outcomes require reconciliation or application idempotency before resubmission, not an automatic retry. Resource disposal follows persistence and can fail after a confirmed commit. Returning a failed command in that situation does not undo the write. # Command filters > Add ordered command verdicts without replacing mandatory authorization or validation. Use a filter for a reusable execution gate, not to hide command business logic in a second handler. `Filter.OnExecution(context.Context, *Invocation)` returns a `Result[NoResponse]` fragment and an inspectable local error. `FilterFunc` adapts a callback. ## Register lazily `AddFilter(name, factory, keys...)` registers an ordinary filter. `AddAuthorizationFilter` registers the authorization category; `AsAuthorizationFilter` wraps an existing Filter. Names identify extensions, not DI keys. Duplicate names within the filter category fail deterministically. Factories run only at their stage and return borrowed instances; resource holders own disposal. A direct instance captured by a factory must support concurrent independent roots. Build checks declared keys without resolving them. ## Preserve phase order Membership and declaration authorization always run first. Custom authorization filters follow, then ordinary filters in registration order, then model and explicit validation. Registration order cannot move an ordinary validator ahead of authorization. Fragments AND authorization and append diagnostics, preserving the first denial reason and outer correlation. Input-stage severity filtering discards nonblocking findings before deciding continuation. A zero fragment denies; continuation requires explicit `Success`. There is no C#-style null-result skip. ## Keep the gate safe Panics and callback errors pass through application boundaries; cancellation never becomes validation. Exception-bearing fragments are production-redacted. Validation findings and explicit denial messages are caller-authored feedback: do not place secrets in them. A filter runs during both Execute and Validate. It must not use Handle-like external writes as a substitute for preparation or effect handling. Validate-only bound executors refuse Execute, and ignored nested failures remain sticky. # Execute a command > Execute, validate, or borrow an operation scope while preserving failure and response contracts. Use the pipeline when a backend caller needs the same command boundary as other callers, rather than calling Handle and silently skipping its guards. ## Build and execute `Registry.Build(PipelineOptions)` validates metadata, graph shapes, authorization declarations, route collisions, and declared DI keys without constructing application dependencies. Success freezes the registry; failure leaves it editable. The resulting `Pipeline` supports concurrent independent roots. Shared closures and extensions need their own concurrency contract. Execution excerpt assuming a built pipeline and the Greet declaration from [model-bound commands](/arc/go/backend/go/commands/model-bound/): ```go result, err := commands.Execute[string](ctx, pipeline, Greet{Name: "Ada"}) ``` Check the local `err`, then inspect `IsAuthorized`, `IsValid`, and `HasExceptions`. Expected validation findings and business denials may return a failed envelope with nil error. Infrastructure and cancellation errors remain inspectable with `errors.Is`/`errors.As`. `Response()` distinguishes absence from scalar zero. The typed helper checks a known incompatible response contract before any application callback. Unknown contracts are checked against the actual response after execution; a mismatch returns `ErrResponseType` and a failed envelope without a response. A compatible typed call may still return no response. ## Follow the stages 1. Validate input, options, and registration; establish correlation and a fresh receipt without changing security presence. 2. Prepare authorization and tenant requirements; open or admit resources. 3. Install context values and resolve the command key; begin root participants. 4. Enforce membership, declaration authorization, and authorization filters. 5. Run ordinary filters and model/tag/registered/explicit validators. 6. Apply input severity; run Provide and its controls, then recheck security and invoke Handle. 7. Classify returns, update context, select one response, preflight controls, and consume effects. 8. Merge sticky nested failures; complete entered participants in reverse order. 9. Dispose owned resources and finalize once. Any failure removes response presence. `ExecuteOptions` accepts zero or one value. Command severity floors cannot be loosened by the caller. [Returned warnings](/arc/go/backend/go/commands/validation-severity-filtering/) have deliberately different semantics from input-stage findings. ## Validate without handling `Validate` follows context, resources, membership, authorization, filters, and validation. It never activates completion participants, Provide, Handle, or response handlers. Its verdict is advisory, not a transaction or concurrency guarantee. ## Own or borrow resources Unscoped Execute opens fresh resources through `OpenResources` or `ScopeFactory`; these options are mutually exclusive. A nil opener creates an empty guarded scope. `ExecuteScoped` and `ValidateScoped` admit a supplied `execution.Scope` and never close its resources. The caller must keep borrowed resources alive until execution returns. With a DI factory, borrowed DI resources must satisfy its explicit optional `ScopeOwner` capability; ownership is never inferred from resolver equality. Callbacks receive expiring, non-closing scope views. Use `execution.ResourcesAs[T]` for plain holders or `execution.Resolve[T]` for resolver-backed holders. Generated adapters can call `Prepare` with separate stage manifests; manifests check the dependency catalog, not eagerly resolve dependencies. ## Handle failure honestly Application panics become `execution.PanicError`. Production exception text is `An internal error occurred while processing the request. See server logs for details.` Development details require `ExposeExceptionDetails`; nil Logger means no automatic logging. Callback-supplied exception fragments are redacted too. Completion and disposal use a cancellation-detached, cooperative budget (30 seconds by default). This keeps cancellation from hiding cleanup, but does not forcibly interrupt uncooperative callbacks. Failed response suppression is not rollback and does not prove an external write never committed. # Validate commands > Combine model Validate methods, required tags, registered concept rules, and explicit validators. Reject bad input before fetching handler dependencies. Keep reusable value invariants with concepts, cross-field rules with the command, and preparation-dependent decisions in Provide or Handle. ## Use the model convention A command implementing `Validate(context.Context) ([]validation.Result, error)` is validated automatically, even with manual handler registration. The method set is exact: a registered value never synthesizes a pointer receiver. This is a Go-specific convention; it does not claim a C# model Validate method is automatically invoked. A nil `PipelineOptions.Validation` still enables model methods and supported tags. `WithoutModelValidation[C]` explicitly opts out of graph/model/tag validation for that command; separately attached `WithValidator` callbacks still run. ## Compose registered and explicit rules Supply a built `validation.Graph` for registered model and concept validators. The graph permits one registry validator per exact type, ordinary or concept. Model methods, registered rules, and required tags have distinct identities; explicit `WithValidator` and `WithScopedValidator` chains remain additive and ordered. Factories activate only during validation, after authorization. Explicit validator factories may declare DI keys; Build checks the catalog without activating them. Ordinary missing dependencies remain infrastructure failures, not fabricated business findings. ## Know the bounded graph The graph validates nonnull runtime nodes before descending readable exported fields and collections in declaration/element order. Shared pointer identity stops cycles and duplicate instance validation; equal distinct instances remain distinct. Collection paths retain their owning member without numeric indices. Concept findings attach to their owning field, dropping concept-internal `value`. `validate:"skipConcept"` suppresses only the immediate child’s concept validator. Arbitrary maps are opaque; use an explicit validator. Traversal is bounded to 64 levels and does not invoke reflective getters. `validate:"required"` is a separate top-level tag pass. It rejects nil and empty/whitespace strings, not supplied numeric zero or false. Rich range, length, email, regex, recursive annotations, and client rule projection are not implemented here. ## Distinguish rejection from infrastructure `validation.Invoke` validates findings and maps unexpected validator failures to reason `validatorFailed` and exactly `The value could not be validated.` The local error retains diagnostics without publishing them as a second exception. Cancellation remains cancellation. Input-stage [severity policy](/arc/go/backend/go/commands/validation-severity-filtering/) decides which findings survive. A preflight Validate success is advisory, not proof of a concurrency-safe decision or a committed effect. # Protected decision reads > Opt a command into invocation-owned, enrolled read-model decisions and supply them through the provider-neutral seam. A command that decides from a read model can lose a race: another command changes the same instance between the read and the write. A protected decision read lets a provider guard against that. The read belongs to one command invocation and is enrolled with the provider’s completion owner. The provider must reject the whole batch on a competing write; Arc’s root module checks provenance but cannot enforce a database transaction. The `commands` package owns the profile, the cache and the provenance checks. A provider integration such as Chronicle supplies the evidence, and you never handle provider tokens directly. The root package imports no provider. ## Choose a profile Each command has one frozen `DecisionProfile`, readable through `Registration.DecisionProfile()`: | Profile | Option | C# counterpart | Decision reads | | --- | --- | --- | --- | | `DecisionsUnmarked` (default) | none | unmarked command | Refused. Ordinary read-model injection stays advisory. | | `DecisionsProtected` | `WithProtectedDecisions[C]()` | `[ProtectedDecision]` | Invocation-owned, enrolled and verified before `Handle`. | | `DecisionsUnprotected` | `WithUnprotectedDecisions[C]()` | `[Unprotected]` | Refused by Arc; the provider serves an advisory, detached snapshot. | The two options share one slot, so supplying both fails with `ErrDuplicate`. ## Admission happens before any callback The protected profile can’t certify arbitrary validation code, so it’s admitted only for commands that run none: * `Register` refuses the profile unless `WithoutModelValidation` is supplied. It also refuses it with any `WithValidator` or `WithScopedValidator` option, or with command operations. The error is a `*RegistrationError` that matches `ErrDecisionProfile` and `ErrInvalidRegistration`. * `Build` refuses a protected command unless the registry has a certified provider (`Registry.AddDecisionProvider`) and a completion owner (`AddDeferredCommitParticipant`). A failed `Build` leaves the registry editable. * At run time, `ReadDecision` repeats these checks and refuses an unmarked or unprotected command, an uncertified provider, or a missing owner before calling any provider stage. Provider certification is composition, not a runtime flag. A `DecisionProvider` from `NewDecisionProvider()` is an opaque identity; a read whose target names a provider that this registry never added is refused. ## Read and provide a decision This excerpt comes from the compiled `ExampleWithProtectedDecisions` in `commands/decision_example_test.go`, which also declares `BookRoom`, `Room`, the `roomSource` decision source and the registry composition. ```go err := commands.Register(®istry, commands.WithProtectedDecisions[BookRoom](), commands.WithoutModelValidation[BookRoom](), commands.Prepare(func(ctx context.Context, inv *commands.Invocation, c BookRoom) (commands.Preparation[*commands.DecisionRead], error) { target := commands.DecisionTarget{Provider: provider, Model: reflect.TypeFor[Room](), Store: "hotel", Namespace: "default", Key: c.Room} read, err := commands.ReadDecision(ctx, inv, target, roomSource{}) if err != nil { return commands.Preparation[*commands.DecisionRead]{}, err } return commands.Provided(read), nil }, func(_ context.Context, _ *commands.Invocation, c BookRoom, read *commands.DecisionRead) (commands.NoResponse, error) { // Arc verified read before Handle; decide from it. fmt.Println("booking", c.Room, "booked:", read.Value().(*Room).Booked) return commands.NoResponse{}, nil })) ``` In practice the provider integration wraps `ReadDecision` in a typed API, and its typed value implements `DecisionEvidence`. For Chronicle, see [Chronicle decision reads](/arc/go/backend/go/chronicle/decision-reads/). `ReadDecision` shares one acquisition per command frame and target. Concurrent callers wait for the same fold; the first caller’s context owns it, and a canceled waiter doesn’t cancel it. A failed acquisition is cached for the frame and never retried. Every call checks the evidence again and enrolls it again with the current owner. Every target field (provider, model type, store, namespace and key) is part of the cache key. Nested commands and validation-only runs get their own cache. A `Validate` run never enrolls or uses the owner, although building a protected-command pipeline still requires one. ## Foreign, nil and expired reads are refused before effects After `Provide` and before `Handle`, Arc verifies every read carried by a payload that implements `DecisionEvidence` (`*DecisionRead` does). It refuses: * a nil or zero `DecisionRead`, or, for a protected command, evidence that carries no reads; * a `DecisionRead` overwritten with another issued read, even when both contain equal or non-comparable provider evidence; * a read issued to another invocation, another command, a validation-only run or another pipeline; * a read the provider’s `Check` no longer accepts; * a read used after its callback expired, or under a changed principal or correlation. This applies to every profile: an unmarked or unprotected command has no issued reads, so any read it carries is refused. Such a command may still return a provider-typed `DecisionEvidence` value that carries no reads, which is how a provider serves an advisory snapshot. Call `VerifyDecision` yourself before relying on a read in a callback Arc doesn’t verify. Arc only inspects payloads that implement `DecisionEvidence`; it doesn’t look inside other structs or slices. Every refusal wraps `ErrDecisionRead` and keeps the provider’s cause for `errors.Is` and `errors.As`. It is an infrastructure failure, never a validation finding, so `ExecuteOptions.AllowedSeverity` can’t turn it into success. ## Implement a provider A provider integration implements `DecisionSource` for each target: | Stage | Contract | | --- | --- | | `Admit` | Refuse unsupported models, such as classified ones, before any acquisition. | | `Acquire` | Return opaque, non-nil provider evidence. Never accept an application-created token. | | `Check` | Validate the evidence’s lifetime and target again; called at every issue and verification. | | `Enroll` | Register the evidence with the provider’s current completion owner. Not called in validation-only runs. | Arc checks callback and security continuity between stages, holds no lock while calling them, and converts a panic to an error. Use `CurrentDecisionProfile` to serve advisory snapshots for unprotected commands and to refuse decision reads for unmarked ones. ## Limits * The protected profile admits no model validation or custom validators. C# Arc certifies parameterless validators that Arc constructs itself; Go has no equivalent certification yet. * Arc doesn’t detect decision reads delivered through other dependency paths; only `Provide` payloads and explicit `VerifyDecision` calls are checked. * Competing-write rejection, lost-acknowledgement handling and release semantics belong to the provider and its completion owner. The protocol gives no authenticated server proof that a read was enrolled. # Model-bound commands > Define a command's Handle, Validate, and Provide methods and register typed adapters. Keep the intent and its decision together. A command is a named Go struct, and its receiver is the decoded command, not a DI-created handler object. ## Define the behavior Declaration excerpt, with `context` imported: ```go type Greet struct { Name string `json:"name"` } func (c Greet) Handle(context.Context) (string, error) { return "Hello, " + c.Name, nil } ``` The method remains ordinary Go. Calling it directly bypasses the pipeline; use pipeline execution when you need authorization, validation, resources, or returned effects. Composition excerpt using this declaration and the `commands` package: ```go var registry commands.Registry err := commands.Register(®istry, commands.Handle(Greet.Handle)) ``` Check `err` before building. The adapter carries the exact receiver and raw return type; registration does not invoke the method. The executable `ExampleHandle` in `commands/example_test.go` builds a real pipeline and prints `true true Hello, Ada`. ## Use type-only registration for void methods `commands.Register[ClearLocalCache](®istry)` needs no adapter when `ClearLocalCache` implements exactly `Handle(context.Context) error`. A value registration never synthesizes a pointer receiver. Pointer and value registrations are exact, distinct types; give them distinct public identities if you register both. Go cannot infer an arbitrary method return type from the command type alone. Result-bearing methods therefore use `Handle(C.Handle)`. Methods with services use a closure, `Invoke`, or `Scoped`. The compiled examples cover each approach without a container. ## Separate preparation `WithProvide(C.Provide, C.Handle)` passes one exact typed payload to Handle after authorization and input validation. Multiple prepared dependencies belong in an application struct. A supplied nil pointer remains an explicit payload; it never falls back to DI. Use `WithPreparation` when Provide needs controls: `Provided` carries a usable payload, `ProvidedWith` adds a verdict, and `StopProviding` always skips Handle, including successful early termination. Zero `Preparation` fails with `ErrInvalidPreparation`. Provide errors never fabricate a payload. Validate invokes neither stage. ## Add model validation and metadata A matching `Validate(context.Context) ([]validation.Result, error)` runs automatically through the validation graph. This is a Go convention, not a claim that C# invokes a command Validate method. Tags such as `validate:"required"` describe supported input rules; [validation](/arc/go/backend/go/commands/command-validation/) defines their limits. Public identity defaults to the named type and the registry’s explicitly configured namespace, or global. Never derive routes from a Go import path. Pin migrations with `WithDescriptor`; individual metadata options override it regardless of option order. Repeated singular options and multiple handlers fail. An optional blank declaration field can carry runtime-readable metadata: ```go type PublishItem struct { _ struct{} `json:"-" arc:"command,name=PublishItem,path=/items/publish" authorize:"roles=Editor|Admin"` ID string `json:"id" arc:"key"` } ``` This excerpt declares metadata only; supply a supported Handle method or adapter before registering it. Declaration authorization uses the same compiler as explicit options. See [authorization](/arc/go/backend/go/commands/model-bound/authorization/) before exposing the command. # Command authorization > Protect command declarations and tenant membership before ordinary dependency activation. Reject callers before constructing their validators or handler dependencies. Command authorization is mandatory pipeline work; a custom authorization filter adds restrictions, never replaces the declaration. ## Declare access `WithAuthorization[C](metadata.Authorization{...})` replaces the command declaration. Within one requirement, roles are OR; repeated requirements are AND. A nonnil empty declaration requires authentication. `AllowAnonymous` cannot coexist with restrictions at the same level. Named policies must exist when Build compiles the catalog; authentication schemes remain unsupported. A nil evaluator does not bypass authorization: Build compiles the registry catalog with an empty authorization registry. Undeclared commands are public when that registry has no fallback. For a protected application baseline, supply an evaluator built with your fallback policy. The evaluator must cover every target with exactly matching declarations, not merely matching names. ## Understand activation order Resource opening, context-value providers, key resolution, and root participant Begin are trusted preauthorization work. Keep those factories cheap and lazy. Membership then runs even for public commands, followed by declaration authentication/roles/policies and custom authorization filters. Only an allowed command constructs ordinary filters, validation factories, or scoped handler adapters. Rechecks preserve principal and tenant values **and their presence bits**; a borrowed resource scope never grants permission. ## Keep tenant selection separate `RequireTenant` rejects an unset tenant before opening resources. `Membership` independently decides whether the principal may act in the selected tenant. Neither selecting a tenant nor carrying a system principal bypasses the declared policy. Business denial yields an unauthorized result. A policy infrastructure error yields unauthorized plus safe exception diagnostics, while the returned local error remains inspectable. Inspect `IsAuthorized`, `IsValid`, and `HasExceptions` separately; [pipeline execution](/arc/go/backend/go/commands/command-pipeline/) describes publication and cleanup. # Flat command operations in Go > Declare immediate inline work, inspect commit-aware recovery, and understand the manual operation boundary. Returning work lets you test a command’s decision without performing the write. Arc.Go executes explicitly registered operation declarations through the existing command pipeline and attempts their declared reversals only when observed persistence permits it. This is **Partial C# parity**: manual, flat, sequential operations are supported. Generated operation adapters, receipts, nested operation workflows, and durable recovery are not implemented. ## Declare and register work An operation implements `commands.Operation` and exactly typed `Execute(context.Context, D) error`. Optional reversal implements `Compensate(context.Context, D, commands.OperationFailure) error`. A present but incorrectly shaped `Compensate` fails registration; reflection inspects method metadata but never invokes business methods. Register each exact pointer or value type with `commands.RegisterOperation[O, D]`. Its required `commands.Factory[D]` resolves the **complete execution and compensation dependency bundle** before any returned-value consumer or operation runs. The factory can return an application-owned service without a container, or resolve services through the originating resource scope. Optional DI keys receive the existing static catalog checks without activation. Hidden closure dependencies cannot be checked statically. Business inputs belong in declaration fields; services belong in method parameters. Batch membership is copied, but payloads remain borrowed, including pointer payloads. Do not mutate them during execution. Factories and borrowed services must support concurrent commands. Never retain preflight `Scope`, `Invocation`, or resolver views as execution dependencies. Existing resource owners, not operation declarations, dispose services. The following excerpt assumes the application’s `Reservations` interface provides `Reserve` and ownership-aware `CancelOwned` methods: ```go type ReserveSeat struct{ ReservationID, SeatID string } func (ReserveSeat) CommandOperation() {} func (o ReserveSeat) Execute(ctx context.Context, service Reservations) error { return service.Reserve(ctx, o.ReservationID, o.SeatID) } func (o ReserveSeat) Compensate( ctx context.Context, service Reservations, _ commands.OperationFailure, ) error { return service.CancelOwned(ctx, o.ReservationID) } ``` `commands/operation_example_test.go` contains the complete external plain-Go example: model-bound `Handle`, manual registration, and actual typed pipeline execution. Run it with `go test ./commands -run ExampleRegisterOperation`. Calling `Handle` directly only declares work. ## Return shapes | Declared return | Meaning | | --- | --- | | Concrete operation or `commands.Operation` | One server-only invocation; nil singular values are absent | | `commands.Operations` | Explicit ordered batch; zero and empty batches contain no work | | `commands.Outcome[R]` with `commands.WithOperations[C]()` | Explicit composition of operations, controls, other server effects, and at most one response | | Raw operation slice or array | Rejected; use `commands.NewOperations` | | Raw `any` containing an operation | Rejected; retain meaningful static participation | | Ordinary DTOs or `[]any` | Ordinary response classification; Arc does not recursively search them for operations | `commands.NewOperations` copies membership and rejects nil members, including typed nils. Operation leaves are reserved for Arc: broad response handlers and context updaters never receive them. An operation cannot occupy the explicit response position in `commands.Respond`. Explicit responses retain scalar-zero presence on success; every failed result retracts its response. `WithOperations` is required for erased `Outcome` effects, even if a particular invocation returns no operations. Direct operation and batch signatures infer participation. Empty and absent returns still require a compatible execution boundary. `Validate` runs authorization and input validation only: no operation dependencies, execution participants, preparation, handling, or recovery. ## Execution and scope compatibility Arc validates every declaration, resolves every dependency bundle, processes controls using the command severity policy, consumes other returned effects, then executes operations sequentially. A failed control or consumer prevents operation entry. A journal entry is recorded inside the admitted callback immediately before the business call, after cancellation and security checks. A method returning nil is recorded as completed even if post-call cancellation or ignored nesting subsequently rejects the command. Every scope must opt in statically: * `Registry.AddOperationExecutionScope` promises noncommitting `Begin` and `Complete`, with recovery dependencies usable until resource disposal. * `Registry.AddOperationCommitParticipant` occupies the existing sole terminal slot. Its `ObserveCommit` hook reports provider facts before external operation entry; `Complete` supplies the authoritative final report. * Unclassified ordinary or terminal scopes reject operation commands at `Build`, before factories or `Begin`. They remain supported for non-operation commands. * The installed Chronicle integration registers through `AddOperationCommitParticipant` and hosts operation commands. See [Chronicle compensation](/arc/go/backend/go/chronicle/#compensate-operations-against-chronicle-outcomes) for append observer/resolver prerequisites and remaining limitations. Ordinary scopes complete in reverse entry order, then the sole terminal participant completes. Only afterward does Arc choose recovery, while the originating resource owner is still alive. Pending pre-entry `NotCommitted` observations are not merged as finalized attempts; confirmed, unknown, or mixed persistence facts remain sticky and prohibit operation entry. Same-host nested commands are refused whenever parent or child is operation-capable, before child factories. This includes advisory validation and calls using the original pipeline with the callback context. Ignored refusals reject forward execution; a new refusal during a compensator is recorded as compensation failure, without preventing earlier eligible compensators. `Execution.CheckExplicitCommit` provides the same sticky early-commit guard for future provider integrations; it does not itself commit anything. ## Recovery and observations For successful A and B, partially failing C, and never-started D, eligible compensation runs **C, B, A**, never D. Missing reversal is observable incomplete recovery. | Final `Result.Completion().Disposition` on failure | Recovery | | --- | --- | | `NoPersistedWork` or `NotCommitted` | Reverse declared compensators | | `Committed` | Suppressed; no automatic reversal | | `OutcomeUnknown` or `MixedCommit` | Indeterminate; no automatic reversal | The first failure and response-free result are frozen before later completion or recovery observations. `OperationFailure` supplies invocation index, successful method return, failing-invocation identity, original phase, final `CompletionReport`, original result and original error. Compensation failures never replace the original error or enter HTTP exception messages. Existing completion and disposal error wrapping remains unchanged. `Result.Recovery()` returns a value snapshot when operation processing participated. `Result.OperationOutcomes()` returns copied observations with operation type, execution completion and compensation outcome, never declaration payloads. Both are backend-only; neither changes HTTP JSON or the TypeScript result envelope. `Completion()` remains the sole persistence report. Configure `commands.PipelineOptions.Operations` or root `arc.Options.CommandOperations` with `commands.OperationOptions`. Zero `CompensationTimeout` selects **30 seconds**; negative values fail construction. One fresh detached budget begins after terminal completion, preserving principal and tenant presence, correlation, receipt, command metadata and resource ownership. Callbacks run synchronously and remain joined even if they ignore cancellation. After expiry no further compensator starts, and no timed-out callback is restarted. Pending resource cleanup may resume joining only, never operation execution or compensation. Completed recovery means callbacks returned, not that external history was erased or a retry is safe. Providers own partial-application, lost-acknowledgment, attempt ownership and tenant isolation guarantees. There is no automatic retry, distributed atomicity, durable journal, or crash recovery. Post-pipeline serialization, delivery and disposal failures do not initiate another recovery attempt. ## Parity evidence The authority is C# Arc revision `7c1e78075b737df64f69fddfaae83374f75e3612`: `Arc.Core/Commands/CommandOperationExecution.cs`, `CommandOperationBoundary.cs`, the operation execution/recovery reference, and `Arc.Core.Specs/Commands/for_CommandOperationExecution`. Go evidence is in `commands/operations_test.go`, `operation_execution_test.go`, `operation_boundaries_test.go`, and `operation_example_test.go`: declaration copying and metadata, complete preflight, all five existing dispositions, partial failure, controls, denial and validation nonactivation, sticky nesting, cancellation, panic, detached budgets, resource retention/join resumption, error identity and actual HTTP privacy. These core fixtures alone do not establish Chronicle provider compatibility; the [Chronicle integration guide](/arc/go/backend/go/chronicle/#compensate-operations-against-chronicle-outcomes) describes its separate provider and kernel evidence. # Command return shapes > Choose ordinary payloads, explicit responses, effects, ambiguous values, or infrastructure controls. Make the client response distinct from server-consumed values. Go does not need CLR tuple or union reflection to retain Arc’s one-response rule. ## Select the return grammar | Return | Meaning | | --- | --- | | Ordinary nonnil O | Client response if no consumer handles it | | NoResponse or nil | Successful void output | | Singular validation.Result | Built-in validation control; findings survive after Handle | | authorization.Decision | Built-in authorization control | | `Respond[R](response, effects...)` | One reserved response; every effect must be consumed | | `Effects[R](effects...)` | Every nonnil leaf must be consumed | | `Values[R](values...)` | At most one unhandled response; ambiguity fails before effects | | `Control[R](fragment)` | Explicitly merge infrastructure verdicts and diagnostics | | Zero Outcome\[R] | Successful void output | Slices, arrays, and maps are single leaves, never implicitly flattened. Nested explicit Outcome graphs flatten in declaration order with a 64-level limit. Typed nil leaves are absent; zero, false, and empty string are present. ## Return a receipt and an event value Declaration excerpt, importing `context` and `commands`: ```go type ItemAdded struct { Name string `json:"name"` } type AddNamedItem struct { Name string `json:"name"` } func (c AddNamedItem) Handle(context.Context) (commands.Outcome[string], error) { return commands.Respond("receipt", ItemAdded(c)), nil } ``` Register a matching ItemAdded consumer before executing this command. Otherwise the required effect fails with `ErrUnhandledEffect`. The compiled `ExampleRespond` includes that consumer and prints `receipt Book`; it records an in-memory value rather than appending to Chronicle. ## Avoid false controls A bare `[]validation.Result` from Handle is a payload, not a validation result list. A bare `commands.Result` is also an ordinary DTO. Use `Control` to explicitly adopt an infrastructure result. A user-defined type named Result or Union has no special behavior. `WithResponseType[C,R]` enforces response assignability at runtime, and `WithNoResponse[C]` rejects unconsumed responses. `Registration.ReturnType` is raw O; it need not be the client response type. Conditional consumers leave response metadata unknown unless a contract is supplied. ## Run the examples From the module root: ```bash go test ./commands -run Example ``` The external-package examples cover result method expressions, type-only void registration, dependencies through a closure, model Validate, typed Provide, scoped resources, and returned events-as-values. Examples execute real pipelines without a container. HTTP delivery, proxies, operations, and persistence are outside this demonstration. # Response value handlers > Consume returned values additively and update command context before selecting a client response. A returned value can represent work for a server extension rather than a client response. Register a `ResponseValueHandler` when that interpretation belongs to an integration; keep ordinary command logic on the command. ## Register a consumer `AddResponseValueHandler(name, factory, keys...)` registers a dynamic consumer. `RegisterResponseValueHandler[T]` additionally restricts applicability to exact concrete T or values implementing interface T; its `CanHandle` predicate still decides at runtime. Factories run only after Handle, never during Build or Validate, and return borrowed instances. Resources own disposal. The methods are: * `CanHandle(CommandContext, any) bool` determines applicability. * `Handle(context.Context, *Invocation, any) (Result[NoResponse], error)` consumes a matching value and contributes a verdict. * Optional `UpdateContext(context.Context, *Invocation, any) error` writes context values before response selection. Every matching handler runs in registration order. This is additive dispatch, not first-match selection. Shared instances must support concurrent calls; do not capture a request scope in a long-lived shared consumer. ## Understand the passes 1. Admit leaves through any registered return policies, then flatten explicit outcome graphs in declaration order. Ordinary typed nil leaves disappear; admitted server-consumed typed nils fail. Scalar zeros remain. 2. Run matching context updaters before selecting a response. 3. Reserve an explicit Respond response or select at most one unhandled Values leaf. 4. Reevaluate consumer predicates with the selected response installed. 5. Preflight ambiguity, required consumption, and built-in controls before executing effects. A predicate may depend on the selected response. With Values, declaration order matters: put the response before an effect that becomes consumable only when that response exists. Respond reserves the response explicitly. ## Classify known integration returns `AddReturnAdmission(name, ReturnAdmission{...})` supplies copied exact-type catalog metadata through `Types`, an optional runtime `Check`, or a dynamic Check with no Types. Build never calls Check. A static claim compiles a raw command return to `ResponseNone` unless the command has an explicit contract; dynamic classification leaves metadata unknown. Admission runs before every context updater and consumer. `ServerConsumedReturn` requires a real consumer and rejects typed nil rather than silently dropping it. Ordinary nil responses keep their existing behavior. `Respond` values and built-in controls bypass admission; an explicit response remains a client response. Exact pointer and value types are separate claims, and ordinary slices are never guessed to be event collections. Check validates/classifies only: it must not perform external effects. All leaves must pass before any consumer runs. ## Preserve the control boundary Only singular `validation.Result` and `authorization.Decision` are built-in plain controls. A returned command result DTO or validation collection remains an ordinary payload unless you explicitly adopt it with `Control` or register a consumer. Dynamic consumers do not establish an unconditional no-response contract. Use `Outcome[R]`, `WithResponseType`, or `WithNoResponse` when tooling or typed callers need an enforceable contract. See [response examples](/arc/go/backend/go/commands/response-examples/) for the return grammar. Events-as-values require a real consumer. The compiled `ExampleRespond` records an event in memory; it is not Chronicle persistence, append, or a transaction. The optional [Chronicle integration](/arc/go/backend/go/chronicle/) recognizes its registered events and supplies the transaction consumer. # Command results > Construct Arc command outcomes with safe response presence and standard status selection. A command result tells a caller whether an action was authorized, valid and free of exceptions. It is not an instruction to execute a handler. Result constructors create values only; the pipeline and HTTP application execute and publish them separately. ## Construct an outcome Use `commands.Success(correlationID)` for no response, or `commands.WithResponse(correlationID, value)` for a typed response. The generic `commands.Result[R]` exposes `Response() (R, bool)` so absence cannot be confused with a valid zero value. `commands.NoResponse` names the no-response type argument. To compose failure metadata, use `commands.NewResult(details, response)` with `commands.Details` and `serialization.Optional[R]`. Set `Authorized` deliberately: the zero value denies authorization. Any final failure discards the response. No handler, scope, redactor or validation filter runs during construction. Result constructors copy framework-owned slices, finding member lists and reason details. Response values and application validation state are borrowed; keep them immutable while sharing a result between goroutines. `Details()` returns new framework-owned slices, not mutable access to the stored ones. ## Wire contract The envelope always includes `correlationId`, `isSuccess`, `isAuthorized`, `isValid`, `hasExceptions`, `validationResults`, `exceptionMessages`, `exceptionStackTrace` and `authorizationFailureReason`. Required arrays are `[]` and empty strings remain present. `response` is omitted when absent or nil, but `0`, `false` and `""` remain present on success. * `IsValid()` means the retained validation list is empty, regardless of severity. * `HasExceptions()` means the exception-message list is nonempty. * `IsSuccess()` combines authorization, validity and absence of exceptions. `StatusCode()` selects success **200**, unauthorized **403**, invalid **400**, then exception failure **500**, in that order. Authentication **401** is an HTTP ingress override, not result status precedence. Safe exception messages must already be supplied; result construction does not log or redact secrets. These are output envelopes. To decode a remote response, use a consumer DTO rather than decoding into the private finalized result state. See [validation findings](/arc/go/backend/go/validation/), [query results](/arc/go/backend/go/queries/results/), [the compiling example](https://github.com/Cratis/Arc.Go/blob/main/example_test.go) and [the parity map](/arc/go/parity/). # Traditional action handlers > Adapt an explicit POST handler to Arc command envelopes, with a validate-only route, request-value merging and a raw opt-out. Some endpoints are not model-bound commands. A migration from a controller, a webhook or a one-off administrative action may still want Arc’s command result envelope, validation findings and redaction without registering a command, a `Provide` step or event consumers. `arc.NewActionHandler` adapts one such action to an ordinary `http.Handler` that speaks the Arc command contract. This is the Go translation of C# Arc’s `CommandActionFilter` and `FromRequestModelBinder` (Arc `7c1e780`). It is **Go-specific**: you construct and mount the handler explicitly, and nothing is discovered from attributes. ## Mount an action and its validate route `ExampleNewActionHandler` in `action_handler_test.go` is the complete compiled example. The action receives a fresh, bound input and returns an `ActionResult`: ```go type input struct{ Name string } handler, err := arc.NewActionHandler(func(_ context.Context, value input) (arc.ActionResult, error) { return arc.ActionResult{Response: "Hello, " + value.Name}, nil }, arc.ActionOptions[input]{}) if err != nil { return err } builder, err := arc.NewBuilder(arc.Options{}) if err != nil { return err } for _, route := range []string{"POST /greet", "POST /greet/validate"} { if err := builder.Handle(route, handler); err != nil { return err } } ``` Posting `{"name":"Ada"}` to `/greet` returns status 200 and a command envelope whose `response` is `"Hello, Ada"`. Run the example with: ```bash go test -run ExampleNewActionHandler . ``` No route is registered for you: mount the same handler on the action path and its `/validate` path. A request whose path ends in `/validate` (any letter case) binds the input and runs validation, then stops. It **never** invokes the action or a raw handler. When a request reaches the adapter with another method it receives 405 with `Allow: POST`. A method-qualified mount such as `POST /greet` never routes other methods to the adapter: the router answers those itself (404 from the application), so mount method-independent paths if you need the adapter’s 405. ## What the adapter does and does not do | Stage | Behavior | | --- | --- | | Construction | `T` must be a struct without methods. Invalid configuration fails before any callback runs | | Input | An absent body binds a zero `T`; a present body must be one JSON object. Unknown members are ignored and duplicate declared wire names are rejected, as in Arc’s serializer | | Limits | `MaxBodyBytes` (1 MiB), `MaxQueryBytes` (8 KiB) and `MaxResponseBytes` (16 MiB) default like hosting options; oversized and malformed input never reaches the action | | Validation | Optional `Validate` returns safe findings. By default only Error findings block; `TreatWarningsAsErrors` also blocks Warning, and a single `X-Ignore-Warnings: true` header overrides it | | Action | Runs only for valid input on a non-validate path and an uncanceled request context | | Publication | Success uses the command envelope with `response`. Zero and `false` responses are retained; a nil response is omitted | | Failure | Errors, panics and blocking findings suppress both the response and any raw handler. Exception messages use production redaction regardless of the host environment | The adapter consumes hosting correlation, identity and tenant context, but it is **not** a command pipeline. It does not run `Provide`, catalog authorization policies, operation scopes, transactions, response value handlers or event consumers, and returned values are never interpreted as events or operations. Protect the route with application middleware or an authentication handler, as you would any `Builder.Handle` route. ## Merge request values into the body C# Arc’s `FromRequestModelBinder` fills a default body property only from a nondefault request property. Supply `FromRequest` to opt in to the same rule. It reads query or route values into a fresh `T`; it must not read the body. Return malformed input as `*commands.DecodeError`; other errors are redacted. This excerpt is adapted from `TestActionHandlerExplicitRequestMergeAndContext`, with the options pulled into a variable: ```go type actionInput struct { Count int `json:"count"` Flag bool `json:"flag"` } options := arc.ActionOptions[actionInput]{FromRequest: func(r *http.Request) (actionInput, error) { count, err := strconv.Atoi(r.URL.Query().Get("count")) if err != nil { return actionInput{}, &commands.DecodeError{Cause: err} } return actionInput{Count: count, Flag: true}, nil }} ``` Posting `{"count":0,"flag":false}` to `/action?count=42` binds `{"count":42,"flag":true}`: body zero and `false` are defaults, so the request values fill them. The pinned fixtures in `action_request_test.go` record the rest: * A nondefault body value wins over the request value. * An explicit empty body string wins; a null plain string counts as missing. * A non-nil scalar pointer in the body wins, even when it points to zero. * Wire names match exactly. A differently cased member is unknown and ignored. The merge profile admits flat built-in scalars and scalar pointers only. Custom codecs, concepts, collections and embedded fields fail construction when `FromRequest` is set. Use `*string` when null and empty must stay distinct. ## Return a raw response Set `ActionResult.Raw` to an `http.Handler` to opt a successful action out of the envelope. The raw handler owns status, headers, body and write errors through ordinary `net/http` semantics. It runs after binding and must not retain the writer. Returning both `Response` and `Raw` is an error, and a failed action never reaches `Raw`. The adapter does not buffer or recover a raw handler after it may have committed headers. ## Evidence `action_handler_test.go`, `action_request_test.go` and `action_severity_test.go` cover validate-never-invokes, rejection before the action, failed-response suppression and redaction, zero/false/null responses, raw opt-out, limits and media type, warnings and cancellation, request merging and invalid severities. These are source-derived witnesses of the C# filter and binder specifications, not a paired execution of ASP.NET MVC. See the [parity map](/arc/go/parity/) for the full record and the [command pipeline](/arc/go/backend/go/commands/command-pipeline/) for model-bound commands. # Validation severity filtering > Apply command input thresholds without weakening declaration floors or post-Handle controls. An advisory input warning should not accidentally block a command, but a command’s declared floor must not be weakened by a caller. The policy distinguishes input-stage allowance from returned Handle controls. ## Numeric contract | Severity | Value | | --- | --- | | Unknown | 0 | | Information | 1 | | Warning | 2 | | Error | 3 | Without a caller allowance or command floor, only Error findings survive filters, input validation, and preparation. With `ExecuteOptions.AllowedSeverity`, only findings strictly greater than the allowance survive. Nonblocking findings are removed, not retained as successful diagnostics. `WithBlockOnValidationSeverity[C]` is inclusive. The effective allowance cannot exceed the command floor minus one, and Unknown always blocks an attributed command. Callers may tighten a floor, never loosen it. Out-of-range configuration fails before callbacks. ## Preparation and returned controls differ Preparation controls apply input severity before deciding whether Handle may run. `ProvidedWith` additionally needs a usable typed payload; `StopProviding` always stops even when its fragment is successful. Do not apply that filter after Handle. A singular returned `validation.Result` warning remains in the result and makes it invalid. Explicit `Control` also preserves its findings. Completion failures are never weakened by input allowances. Bare validation collections are ordinary response payloads, not a hidden control grammar. ## Keep ingress separate Backend allowances are trusted application input. The existing `validation.AllowedFromHeader` parser caps untrusted Error allowance at Warning and rejects malformed or out-of-range header values. Actual HTTP hosting remains a separate slice; the command pipeline does not read headers. # Concepts and JSON values > Preserve UUIDs, calendar values, durations and missing input without losing wire meaning. Use named Go values for domain meaning and the `concepts` package for scalar contracts Go does not provide directly. Arc does not require a `Concept[T]` wrapper. ## Portable scalars `UUID`, `DateOnly`, `TimeOnly` and `TimeSpan` are provided by `github.com/cratis/fundamentals.go/concepts`, pinned at `v0.0.0-20261002211957-66acfedfec9c`. Arc.Go’s `concepts` package aliases these shared types and forwards its existing constructors and parsers. You can pass values between the two packages without conversion; existing Arc.Go imports and wire formats are unchanged. `concepts.Concept[T]` also aliases the shared concept declaration, so your models need only Arc.Go’s `concepts` import. | Type | Zero value | JSON output | Input | | --- | --- | --- | --- | | `concepts.UUID` | All-zero UUID | Lowercase dashed string | Dashed 8-4-4-4-12; either hex case | | `concepts.DateOnly` | `0001-01-01` | `yyyy-MM-dd` | Invariant date, years 1–9999 | | `concepts.TimeOnly` | Midnight | `HH:mm:ss.fffffff` | Seconds plus optional 1–7 fractional digits | | `concepts.TimeSpan` | Zero duration | `[-][d.]HH:mm:ss[.fffffff]` | Invariant constant format | Temporal precision is **100-nanosecond ticks**, not Go nanoseconds. TimeSpan keeps the full signed int64 tick range; converting blindly to `time.Duration` can overflow. DateOnly and TimeOnly have no time zone. Invalid parsing returns an error without mutating an existing value. Null requires a pointer or `serialization.Optional`. UUID bytes use RFC network order, not .NET `Guid.ToByteArray()` mixed-endian order. `NewUUID` uses cryptographic version-4 generation. UUIDs are **not** Chronicle event source identifiers: those may be arbitrary strings. A new named type such as `type TaskID concepts.UUID` does not inherit codec methods. Explicitly forward text/JSON methods, or compose the UUID in a domain wrapper with its own codec. String/integer named types keep their scalar JSON kind. ## Declare a domain concept Use `Concept[T]` when Arc should recognize a domain value as a particular scalar, not merely a named Go primitive. This is an interface declaration, not a wrapper or a replacement for your codecs. For example, this complete type declaration requires imports of `encoding/json` and `github.com/cratis/arc.go/concepts`: ```go type Title string func (v Title) ConceptValue() string { return string(v) } func (v Title) MarshalText() ([]byte, error) { return []byte(v), nil } func (v Title) MarshalJSON() ([]byte, error) { return json.Marshal(string(v)) } func (v *Title) UnmarshalText(data []byte) error { *v = Title(data) return nil } func (v *Title) UnmarshalJSON(data []byte) error { var value string if err := json.Unmarshal(data, &value); err != nil { return err } *v = Title(value) return nil } var _ concepts.Concept[string] = Title("") ``` The value declares its representation and implements both encoders; its pointer implements both decoders. For a UUID-backed type, return `concepts.UUID` from `ConceptValue` and forward all four codecs to that UUID. A marker without codecs is invalid. `T` must be an exact supported primitive or shared scalar; concepts cannot wrap another concept, and concept-bearing structs cannot embed fields. Defined calendar types need forwarding codecs. A defined TimeSpan without a marker is indistinguishable from an ordinary int64 and encodes as ticks. Call `serialization.ValidateType(reflect.TypeFor[YourModel]())` during model registration. It prepares cached field plans and checks pointers, collection elements, map keys/values, and exported JSON fields without constructing values or calling your methods. `Marshal` and `Unmarshal` also perform this check before using a type, even for empty collections or omitted nil fields. Invalid declarations preserve Fundamentals.Go’s `concepts.ErrInvalidConcept` and `*concepts.TypeError` for `errors.Is`/`errors.As`; import the Fundamentals package to inspect those errors. Custom non-concept codecs are opaque, and interface values are checked at runtime. Arc keeps using your codecs, never `ConceptValue`, to encode and bind values. Pointer concepts retain Arc’s null semantics; slices and string-key map values retain their scalar encoding. Non-string map keys still need a custom codec. HTTP GET/QUERY arguments use the query binding adapters; when authoring a concept, make its text decoder accept the same canonical scalar as its JSON codec. Arc’s contract tests run Fundamentals.Go’s `CheckJSON` on encoded concept fields; responses do not pay that validation cost. Test your own codecs against `CheckJSON` as well: static recognition cannot prove what a method will emit. ## Timestamps `time.Time` keeps Go’s standard JSON codec: RFC 3339 with an explicit offset, UTC written as `Z`, and up to nine fractional digits with trailing zeros removed. It does not adopt the 100-nanosecond precision of the calendar concepts above. For example, `2026-10-02T03:04:05.123456789Z` retains all nine digits. C# Arc uses System.Text.Json’s default DateTime/DateTimeOffset codecs. DateTimeOffset emits an offset (including `+00:00` for UTC) and at most seven fractional digits. DateTime’s suffix depends on its kind; unspecified DateTime and other accepted C# input forms are not all supported by Go’s RFC 3339 parser. Timestamp parity is partial: normalize to a mutually supported offset and tick precision before crossing languages, or supply a custom codec when an exact DateTimeOffset wire spelling is required. There is no Arc.Go DateTimeOffset codec. ## Missing, null and zero `serialization.Optional[T]` records three states: * Its zero value is missing (`IsPresent() == false`). * `serialization.Null[T]()` is present and explicitly null. * `serialization.Some(value)` is present, including `0`, `false` and `""`. Use `serialization.Unmarshal` on a fresh request value. Like C# Arc’s body codec, it binds the exact case-sensitive wire name. `{"Name":"Ada"}` does not bind to `name`; case variants are unknown fields. Unknown fields are ignored, and exact repeats of declared names are rejected instead of silently choosing the last one. Failure leaves the target unchanged. Requiredness is application validation, not a side effect of transport presence. This does not define future GET argument lookup. Null for a plain `string` (including named string types) is rejected, unlike C# string properties, which accept null under Arc’s default serializer options. Use `*string` to allow null, or `Optional[string]` to distinguish null from missing; do not translate null to an empty string, which loses information. Other non-nullable Go scalar targets also reject null. Use `serialization.Marshal` to omit missing optional properties. Present-null optional model properties are emitted as null to round-trip presence. This differs from C# Arc’s ordinary null-valued properties, omitted by `WhenWritingNull`; clients must accept explicit null when consuming Optional-backed model properties. Standalone `Optional.MarshalJSON` emits null for missing because a scalar encoder cannot omit its enclosing property. Nil application payloads are omitted by result envelopes; empty non-nil slices remain arrays. Initialize model slices explicitly when clients require an empty array rather than an omitted nil property. ## Model codecs Explicit `json` names win. Without a tag, Arc preserves leading acronyms (`ID`, `URLValue`) and camelCases ordinary names (`FirstName` becomes `firstName`). Tag an `ID` field with `json:"id"` to produce lowercase `id`. Arc omits nil struct properties but retains scalar zeros. Explicit `omitempty` or `omitzero` tags use Go’s `encoding/json` omission rules: `omitempty` keeps zero structs, while `omitzero` honors a type’s own `IsZero()` method, including pointer receivers. Map null values and collection null elements stay null. Numeric enum-like Go types remain numbers. Named floating-point literals use `"NaN"`, `"Infinity"` and `"-Infinity"`. Untyped numbers bind as `json.Number`, not float64, so large integer values retain their precision. The foundation codec supports exported fields, pointers, collections and string-key maps. Embedded structs, including unexported ones, promote their exported fields with `encoding/json` precedence: shallower fields win, tags break same-depth ties, and equally dominant promoted names are ignored. Decoding cannot allocate a nil unexported embedded pointer; use value embedding or an exported embedded type. Custom `json.Marshaler`/`json.Unmarshaler` implementations take precedence over `encoding.TextMarshaler`/`encoding.TextUnmarshaler`; text codecs use JSON strings. Pointer-receiver marshalers are honored for addressable values, such as fields reached through a pointer and slice elements. Like `encoding/json`, an unaddressable value does not acquire pointer-receiver marshal methods: pass a pointer when needed. Custom codecs own their wire format and recursion safety. Non-string dictionary keys and unsupported tag options return errors; do not infer polymorphism or schema generation from reflection. Framework encoding/binding is limited to 64 levels, including nested Optional, validation, result and change-set values. HTTP hosts must additionally bound request bytes before binding; this package does not host HTTP. See the [parity map](/arc/go/parity/) for input restrictions and remaining gaps, and the [executable foundation example](https://github.com/Cratis/Arc.Go/blob/main/example_test.go) for scalar use. # Int32 enums on the wire > Accept the names, numbers and comma-separated flags that C# Arc clients send for an int32 enum, while still writing numbers. A Go named integer writes and reads plain numbers. C# Arc’s JSON configuration is more permissive on input: a client may send an enum as its number, its name in any letter case, several names separated by commas, or a decimal string. If your Go backend must accept the same input, mark a named `int32` type with `//arc:enum parse=int32`. Arc-gen generates its parser declaration and `UnmarshalJSON` method over `serialization.NewInt32Enum`. Status: **Implemented** for the pinned Int32 profile. The parser matches Arc 22.48.2 with Fundamentals 7.19.6 (`EnumConverter.cs`). Generation supports standalone enum packages and imported generator-owned parsers; other backing widths and query-string parsing remain unsupported. ## Opt a type in This excerpt follows the compiled generator consumer in `tools/internal/artifacts/testdata/enum`. An enum may live alongside Arc commands or read models, or in its own package: ```go //arc:enum parse=int32 members=Read:Reader type State int32 const ( Zero State = 0 Read State = 1 Write State = 4 Alias State = 4 ) ``` Run arc-gen in that module to generate `zz_arc_generated.go`. Do not write your own `UnmarshalJSON` on an opted-in type: generation diagnoses it before publishing anything. Remove the method and regenerate to resolve the conflict. Other custom codec methods cannot be combined with this profile either. An enum-only package emits just the parser and `UnmarshalJSON`, importing only `serialization`; it does not declare `ArcBindings` or `RegisterArtifacts`. Generate an imported enum package before its consumers. If you add or rename a member, regenerate that package too. When TypeScript proxies are generated, consumer generation and `-check` refuse a stale imported parse map and name the package that needs regeneration. Go-only consumer generation does not check imported parse maps: run `arc-gen -check ./access` for an enum in the `access` package, or `arc-gen -check ./...` to check all packages in the module. Reading `{"state":"Read, Write"}` and writing it back produces `{"state":5}`. Run the generated consumer with: ```bash cd tools go test -run '^TestEnumGeneratedConsumerAndStableTypeScript$' ./internal/artifacts ``` There is no global registration and no marshal hook: normal encoding already writes the number. Declaring constants alone never installs the parser. `NewInt32Enum` copies the map. Names must be nonempty ASCII identifiers that are unique ignoring case; several names may share a value, as `Write` and `Alias` do here. Name Go constants with the **original C# member names**. The `members` option renames only TypeScript exports: `Read:Reader` still parses `"Read"`, not `"Reader"`. TypeScript output does not change when you enable `parse=int32`. The parser is immutable and safe for concurrent use. A nil or zero parser rejects all input. ## What the parser accepts | JSON input | Result | | --- | --- | | `1` | Accepted only if a declared value is 1 | | `9` (undeclared) | Rejected | | `"read"`, `"READ"` | Case-insensitive original name | | `"Read, Write"` | Bitwise OR of the names, even without a C# `[Flags]` attribute | | `"9"`, `"-3"` | Any int32 decimal string, including undeclared values | | `null` | Rejected; use a pointer or `serialization.Optional` for nullable values | | `"Unknown"`, `""`, a fraction or out-of-range number | Rejected | On every failure `ParseJSON` returns zero and `serialization.ErrInvalidEnumValue`. The error never includes the supplied payload, and a failed comma-separated list never returns a partial combination. ## Input and output domains differ Writing accepts any `int32` value. An undeclared value is therefore written as a number that the same parser rejects on input. This asymmetry matches the pinned C# converter and is deliberate. Validate the value before you send it if a client must be able to post it back. Later Fundamentals versions changed numeric flags admission; this profile does not follow them. Ordinary Go integer types keep their open domain, other backing widths are unsupported, and query-string binding is not covered. OpenAPI publication does not yet support this parser’s input semantics. Arc-gen refuses a document containing a `parse=int32` enum rather than publishing an integer-only input schema that rejects valid names and accepts undeclared numbers. Go adapters and TypeScript proxies remain supported without OpenAPI output. ## Evidence `serialization/enum_test.go` covers declaration validation, copying, zero and nil parsers, failure atomicity, null presence, collections and concurrent parsing. `ContractTests/EnumContract` compares 65 int32 reads and 8 writes with actual C# output captured from the pinned packages, and sends the numeric results through the real TypeScript Fundamentals 7.22.0 serializer. The tools generated-consumer test also runs all 65 State and Access reads and all 8 writes through generated parsers and ordinary Arc serialization, verifies failure preserves the receiver, checks enum-only and imported packages, and checks byte-stable TypeScript output. See its [fixture README](https://github.com/Cratis/Arc.Go/blob/develop/ContractTests/EnumContract/README.md) for the exact profile and the [concepts page](/arc/go/backend/go/concepts/) for other wire values. # Hosting options > Look up construction defaults, unary HTTP limits and owned server budgets. `arc.Options` is copied during `NewBuilder`. Collaborator values and callbacks are borrowed and must support concurrent use; slices, pointed-to configuration values and role lists are copied. Negative limits/timeouts fail construction. ## Defaults | Option | Default or contract | | --- | --- | | Environment | Production; only explicit Development opens discovery by default | | Namespace | Empty logical namespace; never inferred from a Go import path | | Routes | `metadata.DefaultOptions()` when nil; nonnil zero values retain meaning | | Authentication | No handlers; typed upstream principal requires HostPrincipal | | Tenancy | Header `x-cratis-tenant-id`; custom resolver and configured selector are exclusive | | Membership / RequireTenant | Off; selection does not grant membership | | OpenResources / ScopeFactory | Optional and mutually exclusive; resources own operation disposal | | DependencyCatalog | Optional static declared-key checks, never dependency resolution | | Clock | `time.Now`; receipts are captured before HTTP authentication | | CleanupTimeout | 30 seconds, cooperative | | ExposeExceptionDetails | False in every environment | | Logger | Nil, silent | | Identity.DetailsProvider | Select sole custom provider or authorized empty-details default | ## HTTPOptions | Field | Zero/default | | --- | --- | | MaxBodyBytes | 1 MiB | | MaxQueryBytes | 8 KiB raw query string | | MaxResponseBytes | 16 MiB encoded publication | | CorrelationHeader | X-Correlation-ID; valid nonconflicting token required | | ReadHeaderTimeout | 5 seconds | | ReadTimeout / WriteTimeout | 30 seconds | | IdleTimeout | 60 seconds | | MaxHeaderBytes | 1 MiB | | ShutdownTimeout | 30 seconds | | QueryReaders | Standard GET and QUERY readers; exact-method replacements allowed once | Owned-server timeouts do not configure embedded servers. Response limits bound publication after encoding, not every allocation inside a trusted custom codec. No process-environment lookup or general-purpose configuration loader runs inside Arc. See [discovery options](/arc/go/backend/go/introspection/) for exposure settings. # Routes and stable identities > Resolve Arc command and query paths from explicit versioned metadata. Use one `metadata.Catalog` for route decisions so later runtime and generated clients do not invent separate conventions. Set `Version: metadata.Version`; unsupported versions fail rather than being silently upgraded. `metadata.Resolve` itself only resolves routes. The application builder uses that same resolution to compile the HTTP handler. ## Identity is not a route `TypeName{Namespace: "Acme.Tasks.Registration", Name: "RegisterTask"}` has identity `Acme.Tasks.Registration.RegisterTask`. Give types stable logical names rather than inferring them from Go import paths. A query adds its method name to the read-model identity: `Acme.Tasks.Listing.Task.All`. That fully qualified identity includes `Task`; its conventional route does **not** include the model name. ## C# route conventions Call `metadata.Resolve(catalog, metadata.DefaultOptions())`. Defaults are the `api` prefix, zero skipped namespace segments, endpoint names included and HTTP QUERY enabled. The zero `Options` value intentionally disables those defaults. With `SegmentsToSkip = 1`: | Identity | Method | Route | | --- | --- | --- | | `Acme.Tasks.Registration.RegisterTask` | POST | `/api/tasks/registration/register-task` | | Same command, validate only | POST | `/api/tasks/registration/register-task/validate` | | `Acme.Tasks.Listing.Task.All` | GET and QUERY | `/api/tasks/listing/all` | C# kebab casing inserts a dash before **every uppercase character** after the first, except immediately after an underscore. `GetURL` becomes `get-u-r-l`, not `get-url`; underscores become dashes. Conventional paths are lowercased, repeated slashes collapsed and trailing slashes removed. A prefix is not itself kebab-cased. Disabling `IncludeCommandName` or `IncludeQueryName` omits the endpoint name only when there is one artifact of that kind in the namespace **after skipping**. Multiple artifacts restore names. Commands and queries form separate groups; explicit-route artifacts still count in those groups, matching C#. ## Overrides and diagnostics `Command.Path` overrides the whole command route. For queries, `Query.Path` (a pointer) wins over `ReadModelPath`. A non-nil empty method path selects convention instead of falling back to the model path. Nonempty explicit paths retain exact casing; query paths also retain trailing slashes. The prefix is not added. Only literal absolute paths are supported in this foundation. Relative paths, wildcards, escaped paths, dot segments, whitespace and repeated explicit slashes fail with configuration errors rather than guessing host-specific routing rules. Every derived endpoint is checked too. Command paths `/create/` and `/` are rejected because appending `/validate` produces repeated slashes. Use `/create` or configure a non-root conventional command route instead. `Resolve` rejects duplicate identities and case-insensitive method/path collisions, including command validation routes. Inspect `*metadata.CollisionError` with `errors.As` for the key and both owners. Results and collision owner ordering are deterministic by identity, independent of registration order. Commands and queries may share a path because their methods differ. Runtime matching is exact and case-sensitive. Hosting adds explicit HEAD for GET, empty 404/405 with deterministic Allow and noncanonical-path rejection without redirects. See the [HTTP contract](/arc/go/backend/go/reference/http-contract/). See [the executable example](https://github.com/Cratis/Arc.Go/blob/main/example_test.go) and [the compatibility ledger](/arc/go/parity/) for the deliberately stricter collision/path behavior and the remaining metadata graph work. # Dependency injection and operation resources > Wire dependencies with plain Go, then opt into Fundamentals scopes when you need a container. You do not need a container to use Arc. Construct your dependencies normally and pass them through constructors, closures, or an operation resource holder. An Arc scope adds security continuity and cleanup ownership, not automatic authorization or a transaction. ## Start with plain Go A holder needs only `Close(context.Context) error`. Use `execution.RunWithResources` to open it for one operation, and `execution.ResourcesAs[T]` inside the callback to borrow the typed holder. The [no-container example](https://github.com/Cratis/Arc.Go/blob/main/examples/nocontainer/main.go) combines this with real authorization and validation calls; run it from the repository root: ```sh go run ./examples/nocontainer ``` It prints `authorized: true`, `validation findings: 0`, `hello, Arc`, and `resources closed: true`. It does not register a command or query pipeline; those pipelines are demonstrated separately by the [HTTP hosting example](https://github.com/Cratis/Arc.Go/blob/main/examples/hosting/main.go). ## Choose operation ownership * `OpenScope(ctx, open)` owns the opened holder. A nil opener creates an empty scope. A holder returned with an error is still cleaned up. * `BorrowScope(ctx, holder)` never closes the holder. You must keep it alive until the wrapper’s admitted work finishes. Typed-nil holders are rejected. * `scope.Use(ctx, callback)` admits synchronous work. Its non-closing scope view expires when the callback returns. Do not retain it or its resolver. * `scope.Close(ctx)` stops admission, joins admitted work, and forwards owned cleanup at most once, retaining its outcome for repeated calls. It never completes command effects. Do not close an owning scope from its own callback. Scopes capture principal and tenant values **and presence**. Changing either invalidates scope use; changing correlation or receipt does not. A scope is not an authorization verdict. `Resources()` is a trusted adapter escape hatch, not a revocable resource reference: use it only inside admitted work and never close or retain its holder. `ResourcesAs[T]` checks type and security but does not resolve anything. ## Opt into Fundamentals dependency injection [Fundamentals.Go](https://github.com/Cratis/Fundamentals.Go) owns the DI contracts and optional container. Arc owns `Resources` and `OpenResources`; a `di.Scope` satisfies `Resources` structurally. You can adapt any `di.ScopeFactory` through `execution.ResourcesFrom(factory)` without adopting the default container. For the optional default container, install Arc’s guard when you build it. This excerpt assumes `registry` is a populated `container.Registry` and imports `execution` and `github.com/cratis/fundamentals.go/dependencyinjection/container`: ```go provider, err := registry.Build(container.WithContextGuard(execution.ContextGuard())) ``` Inspect `err`, and keep the provider alive until operations finish. The guard also protects factory-internal scoped resolutions that do not pass through an Arc resolver. Singleton dependency paths intentionally hide request values; request-specific dependencies belong in scoped registrations, not singletons. Pass `execution.ResourcesFrom(provider)` to `RunWithResources`. Inside its callback, use `execution.Resolve[T](ctx, scope)` or `di.Resolve[T](ctx, scope.Resolver())`. Each resolution checks security and cancellation before and after dependency code and is admitted against concurrent Arc cleanup. Returned dependencies are borrowed for the operation’s lifetime. Plain holders without a resolver return `execution.ErrNoResolver` on this path; nil or unconstructed Arc scopes return `execution.ErrInvalidScope`. Keys are exact: registering a concrete type does not register its interfaces or pointer/value counterparts. The provider has no root resolver. Factories receive restricted, expiring `di.Resolver` views; declare their dependency edges and use `di.BindBorrowed` for interface forwarding so one owned concrete instance is not disposed twice. Read the [Fundamentals DI guide](https://github.com/Cratis/Fundamentals.Go/blob/develop/Documentation/dependency-injection.md) for registration, lifetimes, graph checks, adapter conformance and errors. ## Borrow a compatible DI scope When an operation is configured with a provider and borrows DI resources, call `scope.CheckOwner(ctx, provider)` before using them. It tests the underlying scope through `di.ScopeOwner`, never interface equality or the guarded resolver facade. Foreign and non-DI holders return `execution.ErrScopeOwner`. With a factory that implements only `di.ScopeFactory`, ownership cannot be verified; do not invent an ownership comparison. `ResourcesFrom` checks the optional ownership capability on new scopes too. Arc also honors the optional `di.ContextChecker`, so wrapping a guarded DI scope under a different principal or tenant cannot recapture its authority. Borrowing never transfers disposal ownership or keeps a separately closed provider alive. ## Zero-container guarantee No root-module runtime package depends on `github.com/cratis/fundamentals.go/dependencyinjection/container`. Container use in external tests or an explicitly designated DI example is opt-in. The compiled `examples/nocontainer` source imports **no** `dependencyinjection` package and executes authorization, validation, and scoped plain resources. The small, standard-library-only DI contract package may occur transitively via `execution`, including from authorization and validation. This guarantee excludes a container implementation, not those contracts. CI checks the runtime dependency graph and the example’s direct imports with `python3 scripts/check-no-container.py`; ordinary tests verify the example output. ## Migrate from the unreleased services API The Arc `services` package and `execution.Run` are removed without compatibility wrappers. Replace registrations with Fundamentals DI registrations, replace `Run(ctx, provider, ...)` with `RunWithResources(ctx, ResourcesFrom(provider), ...)`, and replace `services.Resolve[T]` in operation callbacks with `execution.Resolve[T]`. Callbacks now receive `*execution.Scope`, not a container scope. Callback panics become inspectable `execution.PanicError` diagnostics joined with cleanup failures, rather than being re-panicked. For plain wiring, skip DI altogether and keep using `ResourcesAs[T]`. # Backend operation diagnostics > Record bounded, payload-free command and query outcomes and export them on your own schedule. When a command starts failing validation or a query slows down, you need counts and durations per artifact, not request logs full of payloads. Arc.Go’s optional `observability.Recorder` counts each command and query outcome by registered artifact name, keeps a bounded ring of recent observations, and never calls your code while a request runs. You decide when and where to export. This translates the outcomes of C# Arc’s `Observability/PipelineMetrics.cs` and `Observability/OperationOutcomes.cs` (Arc `7c1e780`). Status: **Partial**. Cumulative counts and duration sums replace .NET meter histograms. There is no tracing provider, no histogram buckets and no built-in exporter. ## Record a pipeline `ExampleRecorder` in `observability/example_test.go` is the complete compiled example. It builds a command pipeline with a recorder, executes one command and drains the result: ```go recorder, err := observability.NewRecorder(observability.Options{}) if err != nil { panic(err) } var registry commands.Registry if err := commands.Register(®istry, commands.Void(func(announce, context.Context) error { return nil })); err != nil { panic(err) } pipeline, err := registry.Build(commands.PipelineOptions{Diagnostics: recorder}) if err != nil { panic(err) } result, err := pipeline.Execute(context.Background(), announce{Message: "not in diagnostics"}) if err != nil { panic(err) } fmt.Println(result.IsSuccess()) // Export explicitly, after execution. No callback runs inside the pipeline. report := recorder.Drain(64, func(event observability.Observation) error { fmt.Println(event.Outcome.String()) return nil }) fmt.Println(report.Removed) ``` It prints `true`, `success` and `1`. Run it with: ```bash go test -run ExampleRecorder ./observability ``` For a hosted application, set `arc.Options.Diagnostics` instead. The builder passes the same recorder to its command and query pipelines, and HTTP ingress records matched requests that fail before dispatch: credential failures, command body decode failures and query reader failures. Query pipelines take `queries.PipelineOptions.Diagnostics` when you build them yourself. ## What an observation contains An `Observation` holds copied scalars only: artifact label, operation, transport, phase, outcome and elapsed seconds. It cannot carry a payload, an argument, a principal or an error value. | Field | Values | | --- | --- | | `Operation` | `Command`, `Validate`, `Query` | | `Transport` | `Unknown` (commands), `SnapshotTransport`, `ObservableTransport` | | `Phase` | `Completed`, `Opening`, `Consumption`, `FirstDelivery`, `Delivered`, `Joined`, `Cleanup` | | `Outcome` | `success`, `validation`, `authorization`, `append_rejected`, `cancelled`, `error` | Authorization takes precedence over other failures. `cancelled` means a failed operation whose context was canceled. `append_rejected` maps constraint and concurrency validation reasons. Observable queries record separate phases. `Opening` covers admission and source activation; `Consumption` covers one consumer’s terminal outcome; `FirstDelivery` and `Delivered` count acknowledged data results; `Joined` and `Cleanup` measure time until source and resource ownership is actually released. Suppressed, denied, obsolete or failed deliveries do not count as delivered. A cleanup that times out or panics stays retained until its owner releases it. ## Bounds and labels | `observability.Options` field | Default | Maximum | | --- | --- | --- | | `ArtifactLimit` | 1000 registered labels | 1000 | | `EventCapacity` | 256 queued observations | 65536 | Pipelines register their frozen command and query identities when they build. Unknown names and names past the limit are labeled `_other` (`observability.Other`), so request input can never grow the label set. Names longer than 512 bytes also use `_other`. One recorder can be shared by several pipelines; the limits are recorder-wide and earlier registrations win. Metrics are cumulative and never dropped. The event ring is separate: when it is full, new events are dropped and `Snapshot().Dropped` counts them. `Delivered` phases update metrics only, so a busy stream does not fill the ring. ## Export on your schedule * `Snapshot()` copies the metrics in stable label order, the queued events and the dropped/export-failure counters. It never calls an exporter. * `Drain(limit, observe)` removes up to `limit` events, releases the lock, then calls `observe` synchronously for each. Errors and panics are counted in `ExportErrors` and `ExportPanics` without retaining their values. Removed events are never retried. A blocked exporter blocks only the goroutine that called `Drain`. The recorder starts no goroutines and has no timeout; own cancellation and export I/O in your application. A nil `*Recorder` and the zero value are disabled. ## Inspect query health To see how many observable queries are open, closing or still retained, enable the protected [query health endpoint](/arc/go/backend/go/queries/query-health/). It shares the same recorder for its own diagnostics. ## Evidence `observability/recorder_test.go`, `commands/diagnostics_test.go`, `queries/diagnostics_test.go`, `queries/diagnostics_observation_test.go`, `queries/diagnostics_retained_test.go`, root `diagnostics_test.go` and `diagnostics_http_test.go` cover the bounds, outcomes, observable phases, retained cleanup, HTTP pre-dispatch failures and the absence of payload values. See the [parity map](/arc/go/parity/) for the full record. # Execution metadata > Carry trusted identity, tenant, correlation and receipt values through explicit Go contexts. A job should not accidentally inherit the user or tenant of the request that scheduled it. You carry immutable metadata in explicit contexts and choose the actor for independently initiated work. These are foundation APIs, not command pipelines, authentication, authorization, or HTTP hosting. ## Carry identity and tenant values `identity.NewPrincipal` copies trusted application input. It does **not** verify credentials. Only a verified adapter or trusted application code should construct an authenticated principal. A nonempty `AuthenticationType` determines `IsAuthenticated`; an ID or arbitrary claim never authenticates the caller. Roles are explicit, exact, case-sensitive strings. `HasRole` also requires authentication. Claims retain duplicate types and input order; `Claim` returns the first exact match. `Roles` and `Claims` return independent slices. `Equal` compares every captured field and ordered slice content. `identity.System("jobs")` creates an authenticated `[System]` actor with the supplied roles and C#-compatible subject, name, and role claims. It is not an authorization bypass: it does not acquire roles you did not supply. `tenancy.ParseID` accepts arbitrary tenant text, preserves case and interior spaces, and rejects control characters, invalid UTF-8, and surrounding whitespace. It does not impose UUID, DNS, or database-name restrictions. Invalid text and non-string JSON return `tenancy.ErrInvalidID` without exposing rejected text or changing the receiver. JSON null is rejected. | Value | `String()` | `IsSet()` | `IsDefault()` | | --- | --- | --- | --- | | Zero or `ParseID("")` or `ParseID("[NotSet]")` | `[NotSet]` | false | true | | `tenancy.Default()` | `Default` | true | true | | Other parsed text | Original text | true | false | NotSet and named Default remain unequal. A tenant ID alone proves no membership. `WithPrincipal` and `WithTenant` derive contexts without changing parents. `PrincipalFrom` and `TenantFrom` return a presence flag: an explicitly installed anonymous principal or NotSet tenant shadows a parent and still reports present. There are no package-global current values or restoration leases. ## Correlation and receipt `correlation.ID` is a type alias of `concepts.UUID`, so existing command/query result fields need no conversion. `correlation.DefaultHeader` is `X-Correlation-ID`; no HTTP handler is installed by this package. | Function | Behavior | | --- | --- | | `correlation.Parse(text)` | Trim whitespace, parse a nonzero dashed UUID; invalid input returns `ErrInvalidID` | | `correlation.Normalize(text)` | Keep valid input; replace missing, malformed, or zero input with a cryptographic version-4 UUID | | `correlation.Resolve(ctx, text)` | Prefer valid text, then a nonzero context ID, then generation | | `correlation.FromContext(ctx)` | Read only; return zero when absent | | `correlation.WithID(ctx, id)` | Install an explicit ID; zero shadows parents | `WithID` and `FromContext` delegate to `github.com/cratis/fundamentals.go/correlation` using its shared context key. An ID installed through Arc is visible to Fundamentals and Chronicle.Go consumers of that context, and vice versa, without conversion. Reading never generates an ID; an explicit zero shadows any parent ID across both packages. Both accessors panic on a nil context, as before. Generated IDs preserve generation failures. Correlation conveys no authorization or ownership authority. Other C# Guid input spellings are not supported. Ingress parsing, generation policy and header handling remain Arc-owned. `execution.WithReceivedAt` stores a UTC receipt without a monotonic component; `ReceivedAt` returns the value and its presence. Nested overrides leave parents unchanged. `Capture` copies only Arc metadata, never a scope, provider, request, or cancellation ownership. `NewContext(ctx, metadata)` installs the supplied principal and tenant even when they are anonymous/NotSet. Zero correlation generates a UUID; zero receipt uses `time.Now`. Callers needing an injected clock supply its receipt explicitly. The new context retains the parent’s cancellation and deadlines. ## Run an independent job Start with [plain Go operation resources](/arc/go/backend/go/core/dependency-injection/), then call `execution.RunWithResources` using an application/job lifetime context. This excerpt assumes `jobCtx` and an `open` function of type `execution.OpenResources` are available and imports `context`, `execution`, `identity`, and `tenancy`: ```go err := execution.RunWithResources(jobCtx, open, execution.Metadata{ Principal: identity.System("jobs"), Tenant: tenancy.Default(), }, 0, func(ctx context.Context, scope *execution.Scope) error { return scope.CheckContext(ctx) }) ``` Inspect the returned `err`. The callback runs synchronously in a fresh scope, receives a non-closing view, and expires on return. Cleanup follows callback completion; callback, cancellation, and cleanup errors are joined. It creates no goroutine. Callback panics become inspectable `execution.PanicError` diagnostics without disclosing their payload in error text. The unreleased `execution.Run` API is removed; use `RunWithResources` for both plain holders and DI factories. Zero cleanup timeout means 30 seconds. Negative timeouts and nil contexts or callbacks return `execution.ErrInvalidArgument`. A nil opener means empty resources. Cleanup uses a bounded `context.WithoutCancel` child: it preserves metadata but does not inherit caller cancellation. Deadlines are cooperative, not forced termination. Do not detach a request context to give accidental background work an unlimited lifetime. Scope cleanup releases resources; it never completes or commits command effects. For bounded compatibility claims and remaining pipeline work, read [the parity ledger](/arc/go/parity/). # Run your first Arc.Go application > Serve a model-bound command over HTTP without a container or event store. You want a command endpoint, not a collection of transport handlers. Arc maps registered commands and queries when you build the application. This experimental Go API supports snapshot and observable HTTP. Start with a unary command here; then use [observable queries](/arc/go/backend/go/queries/observable-queries/) for live results. Model-bound adapter generation is experimental; observable generation remains separate tools work. ## Run a greeting command With Go 1.26 or later, run the repository’s [hosting example](https://github.com/Cratis/Arc.Go/blob/main/examples/hosting/main.go): ```sh go run ./examples/hosting ``` The business model is an ordinary Go type: ```go type Greet struct { Name string `json:"name" validate:"required"` } func (c Greet) Handle(_ context.Context) (string, error) { return "Hello, " + c.Name, nil } ``` This excerpt uses `context` from the standard library. The application registers `Greet.Handle` with `commands.Register` and `commands.Handle`, then builds and runs Arc. No separate endpoint-mapping step can be forgotten. ```sh curl -H 'Content-Type: application/json' -d '{"name":"Ada"}' http://127.0.0.1:8080/api/greet ``` The command envelope contains `"response":"Hello, Ada"`. An empty name returns 400. Post the same body to `/api/greet/validate` to validate without invoking the handler. Ctrl+C initiates graceful shutdown. ## Compose your application The sample’s composition function is the minimal path. This excerpt assumes the model above and imports `arc`, `commands` and `context`: ```go func run(ctx context.Context) error { builder, err := arc.NewBuilder(arc.Options{}) if err != nil { return err } if err := commands.Register[Greet](builder, commands.Handle(Greet.Handle)); err != nil { return err } app, err := builder.Build() if err != nil { return err } return app.Run(ctx, "127.0.0.1:8080") } ``` You own signals and error reporting. `Build` validates composition without starting a listener or activating dependencies. `Run` starts admission and owns the server. Continue with [hosting](/arc/go/backend/go/core/hosting/) and [authentication](/arc/go/backend/go/authentication/) before exposing private operations beyond loopback. # Host commands and queries > Choose owned HTTP serving or embed an Arc application as an http.Handler. Arc keeps business execution in the command/query pipelines and transport in the application. You can own a small server or mount the same application in an existing host; neither requires a container or Chronicle connection. ## Build once Create `arc.NewBuilder(arc.Options{})`, register typed commands/read-model queries, and call `Build`. Registries are borrowed construction collaborators; do not call their `Build` methods independently. The root Build attempt seals registration, including on failure. A successful repeated Build returns the same application; a failed attempt returns its recorded error. Create a new builder to correct it. `Catalog` and `Endpoints` return copied snapshots. The latter includes explicit HEAD and mapped framework endpoints. Raw handlers are not fabricated into catalogs. Manual and generated registrations use the same registrar interfaces; the composition generator itself is not delivered by hosting. ## Own a server `app.Run(ctx, "127.0.0.1:8080")` owns listening and serving. `app.Serve(ctx, listener)` takes ownership of an existing listener, including closing it on startup failure. Only one owned server is accepted. Neither API installs signal handlers. Cancellation initiates shutdown with a fresh bounded context and joins the server worker before returning. Unexpected serve/start/cleanup errors remain inspectable. ## Embed a handler Call `app.Start(ctx)` before serving, then mount `app` as an `http.Handler` without rewriting Arc paths. The external host owns its listener, server timeouts and server shutdown. Coordinate that shutdown with `app.Shutdown(cleanupCtx)` using a fresh cleanup budget. Unstarted/stopping applications return empty 503. Start HTTP server shutdown and Arc shutdown together, then wait for both. `http.Server.Shutdown` waits for active handlers, including SSE streams; Arc shutdown cancels those observations. Waiting for HTTP to finish first can hang until the cleanup deadline when a stream is active. The compiled [host shutdown recipe](https://github.com/Cratis/Arc.Go/blob/main/recipes/internal/fixture/lifecycle.go) uses separate fresh cleanup budgets and joins both outcomes. This excerpt requires `context`, `errors`, `net/http`, `time`, and `arc "github.com/cratis/arc.go"` imports: ```go // ShutdownHost stops HTTP admission and Arc observations concurrently, then // joins both. Each has a fresh five-second cleanup budget, not the canceled // signal or request context. Business callbacks must honor cancellation. func ShutdownHost(app *arc.Application, server *http.Server) error { httpContext, cancelHTTP := context.WithTimeout(context.Background(), 5*time.Second) defer cancelHTTP() arcContext, cancelArc := context.WithTimeout(context.Background(), 5*time.Second) defer cancelArc() httpStopped := make(chan error, 1) go func() { httpStopped <- server.Shutdown(httpContext) }() arcError := app.Shutdown(arcContext) return errors.Join(arcError, <-httpStopped) } ``` Use `builder.Handle(pattern, handler)` for small raw endpoints. Patterns use ServeMux syntax. Catch-all and subtree handlers can overlap Arc paths, so you can serve a SPA fallback. Arc’s exact routes retain priority, including their 405 responses. Arc always owns `/.cratis/*`; an unmapped reserved path returns empty 404, never the fallback. Exact ownership conflicts fail Build, including host-constrained patterns. Raw handlers receive resolved ingress metadata and are not implicitly wrapped in result envelopes. ## Serve an embedded public site To serve a small site beside your queries, keep the files and fallback policy in an ordinary `http.Handler`, registered through `builder.Handle("/", handler)`. The [compiled static-site example](https://github.com/Cratis/Arc.Go/blob/main/static_files_example_test.go) embeds three files and supplies an example-local `newStaticSite` helper; it is not a framework API. Run it from the repository root: ```bash go test -run '^ExampleBuilder_Handle_staticFiles$' -v . ``` It prints 200 for `/assets/app.js`, `/app/tasks`, and `/api/site-message` through real HTTP. These excerpts come from that example (the full source includes error handling, the closure query, host startup, and shutdown): ```go site, err := fs.Sub(staticSiteFiles, "testdata/staticfiles/site") if err != nil { return err } ``` `staticSiteFiles` is an `embed.FS` containing exactly `index.html`, `assets/app.js`, and `assets/site.css` beneath that directory. After registering your query: ```go if err := builder.Handle("/", newStaticSite(site)); err != nil { return err } ``` The example calls `app.Start` explicitly, starts an external `httptest.Server`, then shuts down and joins that server before `app.Shutdown` with a fresh bounded cleanup context. This example has no observable streams. That sequential cleanup is only safe when no observable streams are active; otherwise use the coordinated [embedded-host shutdown](#embed-a-handler) above. ### The example’s URL and file contract * GET/HEAD `/assets/` selects that relative file once. A missing asset returns 404, never the shell. HEAD returns the same media type and length without bytes. * `/`, `/app`, and extensionless `/app/...` routes select only `index.html`. Missing index returns 404; `/app/missing.js`, unrelated paths, dotted app paths, and unknown `/api` paths (including mixed case) never receive the shell. * Recognized site URLs reject POST, QUERY, OPTIONS, and DELETE with 405 and `Allow: GET, HEAD`, without opening files. Ineligible paths return 404. * Exact Arc URLs win before the raw callback, even an asset-shaped query path: query successes, redacted failures, denials, and 405 responses stay Arc-owned. Mapped, unmapped, disabled, and mixed-case `/.cratis` paths never reach the site. * The handler rejects dotfiles, directories, trailing-slash directory requests, and ambiguous paths. It performs no directory listing, default-file search, redirect, path cleaning, backslash reinterpretation, or double unescaping. Arc’s existing canonical-path rejection runs first. * Each selected file is opened once, statted, required to be regular and seekable, and closed on every opened-file path. Open/stat/preflight-seek errors produce generic 404 (missing), 403 (permission), or 500 responses without filesystem error text. `http.ServeContent` delivers content directly; this is not a response buffer that converts Arc errors into HTML. Later read, transfer, or close failures never append a shell and cannot retract a published response. * Site-produced responses use `Cache-Control: no-store`, including failures. There is no public-cache or immutable-cache policy and no global header change; Arc-owned headers and statuses stay unchanged. ### Security and compatibility limits This is a **public, nonpersonalized site**. Arc authentication ingress runs before matched raw callbacks: terminal bad credentials prevent site and filesystem access. Anonymous requests can still receive public content. Raw handlers do **not** inherit command/query authorization declarations or fallback policies. For a protected site, supply trusted-principal authentication and an explicit site authorization boundary; do not treat a query policy as asset protection. The runnable source uses immutable embedded assets. A trusted read-only ordinary `fs.FS` must also provide prompt Open/Stat/Seek/Read/Close behavior; the `fs.FS` interface does not provide cancellation or establish trust. `os.DirFS` and `fs.Sub` are **not symlink sandboxes**. Writable or symlink-containing directories and arbitrary filesystem implementations are outside this confinement claim. This bounded composition witness is **Partial** static-file parity. The C# reference at `7c1e78075b737df64f69fddfaae83374f75e3612` is `Arc.Core/StaticFileExtensions.cs`, `Http/StaticFileOptions.cs`, `Http/StaticFilesMiddleware.cs`, `Http/FallbackMiddleware.cs`, and `Http/HttpListenerEndpointMapper.cs`, plus the static/fallback middleware specs. C# uses GET only, configurable roots/default files, and the first root for a broad fallback; a missing file falls through to the eventual 404. This Go example instead admits HEAD, gates fallback narrowly, protects reserved ownership, and chooses no-store caching. The [static-site HTTP tests](https://github.com/Cratis/Arc.Go/blob/main/static_files_test.go) exercise this profile, not identical C# execution, paired C# tests, browser behavior, or broad host/filesystem support. See [the parity map](/arc/go/parity/) for the wider implementation boundary. ## Boundaries POST and `/validate`, GET/HEAD and QUERY use the existing pipelines. HEAD executes the GET read flow but suppresses bytes; it is not a free metadata probe. Snapshot queries stay snapshots even with an SSE Accept header. Hubs, waits, OpenAPI and browser-client conformance remain unsupported. After admission, Arc recovers middleware and raw-handler panics. Before response commitment it returns a redacted command/query 500, or empty 500 for raw, identity and discovery endpoints. After commitment or hijacking it logs the fault without appending an error body. `http.ErrAbortHandler` is re-panicked so the HTTP server retains its abort semantics. Arc’s owned server routes its ErrorLog through the configured `slog.Logger`; a nil logger stays silent. Embedded hosts must configure their own server logger. See [HTTP contract](/arc/go/backend/go/reference/http-contract/) and [lifecycle](/arc/go/backend/go/core/lifecycle/) for error and ownership behavior. # Start and stop application work > Register explicitly owned resources and drain HTTP and direct operations safely. A canceled context signals work to stop; it does not prove work has ended. Arc closes admission before draining callbacks, then stops resources only after the callbacks have joined. ## Register ownership `AddLifecycle(name, participant)` accepts a `Start(context.Context) error` and `Stop(context.Context) error` implementation. Names are unique. Start follows registration order; Stop follows reverse order and includes a hook whose Start failed. No lifecycle callback runs under an application lock. Hooks must honor context and join any workers they launch. Borrowed clients are never closed just because they implement a closing interface. ## State and concurrent calls The application moves from Built through Starting and Running to Stopping and Stopped. Startup failure rolls back entered hooks and is terminal. Concurrent Start callers join the same attempt; repeated successful Start is harmless. A completed startup context does not become the application lifetime. Restart after stop or failure is unsupported. HTTP and direct calls through `app.Commands()`/`app.Queries()` share application admission. Lookup remains available without starting. Nested commands stay owned by the command pipeline, not a second application admission scope. ## Handle an expired shutdown budget Shutdown cancels active callbacks when its caller’s budget expires and returns the context error. If callbacks have not joined, Arc remains Stopping. It does not dispose shared resources or claim success; a later Shutdown can continue joining. Concurrent callers have independent waiting budgets. Hooks own their cooperative stop behavior, and no detached cleanup is launched to hide an unknown outcome. Arc stops observable admission, cancels and joins observations and physical hub connections (including hijacked WebSockets) before ordinary request drain and user hooks. Failed source opening retains cleanup and its application admission lease until workers actually join. See [observable lifetimes](/arc/go/backend/go/queries/observable-queries/). Operation resource `Close` still runs at most once. A holder that owns resumable cleanup can explicitly implement `execution.ResourcesJoiner`: Close initiates cleanup once and `Join(ctx)` waits for that same work without repeating disposal side effects. Context errors mean an incomplete join; a recovered panic leaves completion unknown. Later Close/Shutdown can retry only Join. A returned non-context Join outcome certifies completion, even on failure. Non-context failures and panic diagnostics from earlier attempts remain inspectable through `errors.Is`/`errors.As`; only provisional canceled/deadline waiting errors clear when joining finishes. Plain holders without this capability have final, cached Close errors, including context errors; they must not return while leaving unowned background cleanup. `execution.ErrScopeJoinPending` distinguishes unfinished work from final failures. Every pending **owned** `Scope.Close` result includes an inspectable `*execution.PendingScopeError`, including after successful resource opening in command/query pipelines or `RunWithResources`. Use `errors.As` to obtain it; `Scope()` retains the closing scope so you can finish its Close with a fresh budget. `errors.Is` still exposes `ErrScopeJoinPending`, context errors and other cleanup diagnostics. Retain that scope while subsequent Close calls still report pending; a final failure certifies completion but must still be handled. If resource opening itself fails, OpenScope returns nil plus the pending error when cleanup cannot join. Observable admission reclaims that scope automatically for later Shutdown. Unary hosts do not automatically retain resource joins: your host must retain the pending scope and resume Close, not merely check or log the error. Do not opt a unary holder into asynchronous cleanup without that owner. Borrowed scopes never transfer resource-disposal ownership through this error. For embedded hosting, stop your external server as well. Arc owns its observable connections, not arbitrary raw hijacks; track those in your own lifecycle participant. # Compose HTTP middleware > Add standard net/http middleware without bypassing Arc ingress or pipeline authorization. Use `builder.Use` for middleware of type `func(http.Handler) http.Handler`. The first registration is outermost. Constructors run once during Build, in reverse wrapping order; request callbacks run only after startup. Nil middleware, nil returned handlers and constructor panics fail composition. ## Request ordering Arc establishes correlation and receipt, admits the request, rejects noncanonical paths and installs framework cache policy before application middleware runs. Authentication and tenant resolution run inside that middleware, before endpoint execution. Unknown routes and method mismatches do not challenge credentials. Raw handlers receive authenticated/anonymous principal and resolved tenant metadata. An outer host’s principal is used only with `authentication.HostPrincipal()`. The HTTP receipt timestamp is ordinary metadata here. Only the immediate endpoint pipeline call receives its forwarding marker; backend commands/queries started by middleware, raw handlers or providers capture fresh receipts. Middleware is trusted code. Do not consume a command/QUERY body, remove privacy headers or bypass pipeline authorization. Register owned startup work with `AddLifecycle`, not inside a middleware constructor. ## Diagnostics and response capabilities A nil logger is silent. An injected `slog.Logger` receives bounded route templates, method, correlation, status and duration, never request bodies or argument values. Pipeline stages own their errors; hosting owns transport/provider failures. Ingress callback errors log only the error: client-attributable selector failures use Warn, server faults use Error. Authentication failure reasons and request headers are not logged. Authentication adapters must never include or wrap token text or credentials in returned errors, which the host may log. Request panics are logged at Error without exposing the panic value to clients. Arc does not rewrite committed responses, and propagates `http.ErrAbortHandler`. Response observation exposes `Unwrap` for `http.ResponseController`, and forwards Flusher, Hijacker and Pusher only when the underlying writer supports them. Raw handler streaming does not become an Arc observable subscription or acquire Arc connection cleanup ownership. # Generate adapters and proxies > Generate typed Go registrations and supported TypeScript models, commands, snapshot queries, and observable queries. Writing a registration closure for every command repeats information already in its method signature. The experimental `arc-gen` tool reads opted-in Go packages and writes command, snapshot-query, and declared observable-source adapters for you. An optional application profile also [generates TypeScript models, commands, and queries](/arc/go/backend/go/generation/typescript/), including bounded observable proxies. The tools module pins pushed runtime `78ebbf8` and released Fundamentals.Go `v0.2.0`; it needs no workspace or local replacement. Your business methods remain ordinary Go; the generated code uses the same public registrars and pipelines as manual wiring. To replace repeated service factory registrations, opt in to [constructor services](/arc/go/backend/go/generation/constructor-services/). That generates a separate `RegisterServices` function without changing manual closures or `ArcBindings`. ## Put behavior on the model This declaration excerpt is from the generated consumer fixture. It assumes `context` is imported and `State` is the application’s preparation struct with a `Name string` field: ```go //arc:command //arc:allow-anonymous type Rename struct { Name string `json:"name"` } func (c Rename) Provide(context.Context) (State, error) { return State(c), nil } func (Rename) Handle(_ context.Context, state State) (string, error) { return state.Name, nil } ``` Arc invokes Provide only after authorization and validation. Its exact `State` result supplies one Handle parameter. A failed preparation never invokes Handle. Use `commands.Preparation[State]` when preparation needs validation controls or an explicit early stop. `Validate`-only execution invokes neither business method. For read models, use an unnamed value receiver as a namespace. This excerpt assumes the `Item`, `Arguments`, and `ItemQueries` application types from the consumer fixture and the `context` import: ```go func (Item) AllItems(ctx context.Context, args Arguments, items ItemQueries) ([]Item, error) { return items.All(ctx, args.Prefix) } ``` Mark `Item` with `//arc:readmodel`. The generator discovers exported namespace methods returning their owning model, including slices and pages, and recognized observable source declarations with owning-model emissions. It treats the first ordinary parameter as caller arguments and the remaining parameters as services. Service resolution cannot turn an argument field into a dependency. If a top-level function fits your package better, declare its owner explicitly: ```go //arc:query model=Item name=Recent func RecentItems(ctx context.Context, args Arguments, items ItemQueries) ([]Item, error) { return items.All(ctx, args.Prefix) } ``` For observable behavior, return `observable.Source[O]`, `CurrentSource[O]`, `*State[O]`, or `*Subject[O]` with `error`. Emissions may be the owning model, its pointer, a list/array, value `queries.Page[M]`, or value `queries.ObservedCollection[M]`. The generated performer resolves dependencies only after admission and returns the source without opening it or taking over its lifecycle. Use [manual observable registration](/arc/go/backend/go/queries/observable-queries/) for unsupported provider/source layouts. See the [directive and signature reference](/arc/go/backend/go/generation/reference/) for authorization, private query selection, infrastructure parameters, and rejected signatures. ## Run the tool from source Use Go 1.26 or later. From an Arc.Go source checkout, install the tool separately from your application’s runtime dependencies: ```sh cd tools GOWORK=off GOTOOLCHAIN=local go install ./cmd/arc-gen ``` Keep the source revision you install pinned in your development tooling. Independent `tools/vX.Y.Z` releases are not published by the root release workflow; their publication waits for [Fundamentals.Go#16](https://github.com/Cratis/Fundamentals.Go/issues/16). The tools module pins a fetchable Arc runtime revision without a local replacement. `golang.org/x/tools` stays out of the runtime module. From your application module, select the packages containing artifacts. For example, if they live under `features/`: ```sh GOWORK=off arc-gen ./features/... GOWORK=off arc-gen -check ./features/... ``` The first command writes one `zz_arc_generated.go` per artifact package. The second returns a nonzero exit status if regeneration would change anything. Commit generated files with their inputs. You can put the same pinned executable command in a `//go:generate` directive. Select artifact packages, not a composition package that calls their generated symbols. During analysis, old generated output is hidden so deleted or renamed methods do not prevent regeneration. Handwritten artifact source must therefore compile without referring to generated symbols. ## Register the package once The generated function is `RegisterArtifacts(builder *arc.Builder, bindings ...ArcBindings) error`. Call it from your composition package before `builder.Build()`. Check the error and discard that builder if registration fails. No business method or service factory runs during registration. Without extra service parameters, pass only the builder. For dependency-bearing methods, choose either: * Configure the builder’s `ScopeFactory` with your Fundamentals provider, then call `RegisterArtifacts(builder)`. The adapters declare exact dependency keys and resolve them through `execution.Resolve[T]` inside each execution stage. * Supply the generated `ArcBindings` callbacks. Each callback can close over ordinary application objects or obtain an application resource holder from the supplied operation scope. No container or dependency catalog is required for dependencies whose callbacks you supply. Bindings are named `ResolveReader`, `ResolveWriter`, and so on from the dependency types; inspect the generated struct for exact names when types collide. Nil callbacks use the default resolver and require a matching dependency catalog. Callbacks may run concurrently and must not retain the operation scope. Provide dependencies resolve only during preparation. Handle dependencies resolve only after preparation is accepted. Query dependencies resolve after authorization and argument validation. Model `Validate` methods use the existing runtime convention, not a duplicate generated validator. ## Check a working example The checked-in [consumer declarations](https://github.com/Cratis/Arc.Go/blob/main/ContractTests/internal/generatedconsumer/artifacts.go) and [pipeline tests](https://github.com/Cratis/Arc.Go/blob/main/ContractTests/generated_adapters_test.go) exercise both authoring forms, plain bindings, and the Fundamentals container. From the repository root: ```sh go test -count=1 ./ContractTests -run TestGenerated ``` The tests execute the real pipelines and assert responses, authorization replacement, model validation, and suppression of dependencies in denied or validate-only work. For the underlying manual APIs, continue with [model-bound commands](/arc/go/backend/go/commands/model-bound/) and [model-bound queries](/arc/go/backend/go/queries/model-bound/). # Arc authoring diagnostics > Check model-bound Go declarations with arc-vet and interpret their ARC diagnostic codes. A declaration can compile without being discoverable as an Arc command or query. The experimental `arc-vet` command reports these authoring mistakes without executing application code or writing adapters. It also reports unused preparation values, potentially absent read-model dependencies and contradictory authorization. Use [generator directives and signatures](/arc/go/backend/go/generation/reference/) for the admission contract. ## Run the checker Build from the `tools` module, then run the executable in your application module: ```bash # In Arc.Go/tools: GOWORK=off GOTOOLCHAIN=local go build -o arc-vet ./cmd/arc-vet # In your application module, use the executable's absolute path: /path/to/arc-vet ./... GOFLAGS=-tags=myprofile /path/to/arc-vet ./... /path/to/arc-vet -json ./... ``` Your application must type-check, including existing generated adapters. Build tags, GOOS and GOARCH select the inspected declarations; check each supported configuration separately. Generated files are not inspected. The checker does not generate code or replace `arc-gen -check`, registration checks or runtime validation. No automatic fixes are supplied. ## Diagnostic reference These rules translate the pinned C# analyzers under `Source/DotNET/Arc.Core.CodeAnalysis` at Arc `7c1e78075b737df64f69fddfaae83374f75e3612`. The Go rules use directive opt-in and compiler type identity, including aliases; matching a framework type’s spelling or structure is not enough. | Code | C# analyzer | Go meaning and recovery | | --- | --- | --- | | ARC0001 | `ReadModelAnalyzer.cs` | An explicit query, or implicit method returning its owning-model shape or a recognized observable source, has an invalid result signature or owning-model shape. Return `(O, error)` with an admitted model/source shape. Unrelated implicit helper returns remain outside Go discovery. | | ARC0002 | `CommandAnalyzer.cs` | In a package with artifact-selection directives, a struct with exported nonembedded data and a declared `Handle` lacks an artifact directive or a typed `commands.Register[T]` reference in the same package. Choose `arc:command` for generated registration or `commands.Register[T](builder, commands.Handle(T.Handle))` for manual registration; do not register through both paths. Use `arc:ignore` on a non-command or a command registered from another package or through a generic wrapper. Packages without artifact-selection directives, other selected artifacts, empty service receivers, `Helper`/`Helpers`/`Extensions` suffixes and implementations of `commands.ResponseValueHandler` do not trigger the heuristic. | | ARC0003 | `CommandAnalyzer.cs` | A non-command receiver declares `Handle` taking an opted-in command. Move handling onto the command. Package-level functions are not handler methods. | | ARC0004 | `CommandAnalyzer.cs` | An opted-in command lacks a directly declared exported `Handle`. Lowercase, promoted and ignored methods do not count. | | ARC0005 | `CommandProvideAnalyzer.cs` | `Provide` returns a payload that no `Handle` parameter consumes by exact Go type identity, including the payload of `commands.Preparation[P]`. Return a control-only result or add the payload parameter. Unlike C#, assignability alone is insufficient; multiple exact matches remain an arc-gen error. | | ARC0006 | `InjectedReadModelAnalyzer.cs` | Command `Handle`/`Provide` or an `arc:validator` constructor takes an opted-in read model by value. Consider explicit absence handling, or deliberately retain required-dependency failure. A pointer avoids this warning but does not make an ordinary DI dependency optional: your resolver must implement the intended missing-model behavior. | | ARC0014 | `ReadModelAnalyzer.cs` | An explicit generic function or implicitly discovered generic-receiver method returns its owning query shape. Use a concrete declaration or move/ignore a composition helper. From Go 1.27, this also reports discovered query methods with independent method type parameters. | | ARC0015 | `QueryParameterConceptTypeAnalyzer.cs` | An admitted query directly converts an exported primitive argument field to a Fundamentals concept. Declare the field as the concept if its validation and presence semantics fit the input. This is a bounded syntax check, not dataflow analysis; see the [ARC0015 command reference](https://github.com/Cratis/Arc.Go/blob/develop/tools/cmd/arc-vet/README.md#arc0015-primitive-argument-converted-to-a-concept). | | ARC0019 | `AuthorizationAttributeAnalyzer.cs` | The same opted-in model/query declaration has both `arc:allow-anonymous` and `arc:authorize` (bare, roles or policy). Keep one level. A query’s anonymous override of model-level authorization is valid. | The declaration analyzer reports before arc-gen admission, so malformed command or query shapes and authorization conflicts can have both an ARC finding and an admission error from the ARC0015 analyzer. ARC0015 still requires the package’s handwritten declarations to pass shared generator admission before inspecting query bodies. Unrelated malformed directives remain generator errors. `arc:ignore` excludes a declaration from these new convention checks; it is not a runtime authorization bypass. ARC0002 recognizes typed `commands.Register[T]` references only in the type’s own package, including aliases and inferred type arguments. It cannot see registration from an importing package or trace a user-defined generic wrapper whose `commands.Register[T]` type argument is a type parameter. Put `//arc:ignore` on the command type in either case to suppress ARC0002; keep the manual registration unchanged. The declaration rules also do not inspect arbitrary manual registration callbacks, runtime blank-field metadata tags or Chronicle projection definitions. Imported opted-in command/read-model identities are transported by Go analysis facts for the selected build, so imported aliases do not lose their meaning. Constructors are checked only when explicitly marked `arc:validator`, not merely because their names resemble validators. ## C# rules that do not apply to Go These are deliberate language/host differences, not silently implemented rules. | Code | C# analyzer | Why it is not applicable | | --- | --- | --- | | ARC0007 | `ModelBoundRecordAnalyzer.cs` | Go commands are structs; there is no class-versus-record declaration choice. | | ARC0008 | `ModelBoundRecordAnalyzer.cs` | Go read models are structs; there is no CLR record equality/immutability declaration. | | ARC0009 | `ConceptRecordAnalyzer.cs` | Go concepts use named types and codecs, not inheritance from `ConceptAs` or a class-versus-record choice. | | ARC0010 | `CommandHandleTaskWrappingAnalyzer.cs` | Go handlers return synchronous values/errors; there is no `Task`, `async` or `await` wrapper to remove. | | ARC0011 | `RolesLiteralAnalyzer.cs` | Go has neither `nameof` nor attribute arguments. Role directives contain explicit strings; there is no compiler-linked enum-member spelling to suggest. | | ARC0012 | `ArcArtifactBuiltInExceptionAnalyzer.cs` | Go ordinary failures use returned errors, not a CLR built-in exception hierarchy. This does not endorse panics or uninformative errors. | | ARC0013 | `ValidatorConceptDereferenceAnalyzer.cs` | Go validation does not use FluentValidation expression-tree selectors over nullable `ConceptAs` references. Pointer safety remains the validator author’s responsibility. | | ARC0020 | `AuthorizationAttributeAnalyzer.cs` | There are no ASP.NET attributes or an ASP.NET integration assembly whose absence leaves attributes unenforced. | | ARC0021 | `AuthorizationAttributeAnalyzer.cs` | There is no ASP.NET authentication-scheme attribute setting. Unsupported generator directive options already fail admission. | ## Output and failure handling Text output includes `file:line:column`, an ARC code and a recovery message. Exit `0` means no findings, `3` means findings, and `1` means package loading or analysis failed. A failed analysis can coexist with declaration findings; it is not a clean result. Invalid flags can also fail. The standard `-json` report uses analyzer keys `arcdeclarations` and `arcauthoring` and ARC codes in the `category` field. JSON mode can return zero despite findings or analysis errors: inspect both diagnostic and error records. Use default text mode for an exit-status gate, and use the built executable rather than `go run` when distinguishing exit codes. # Generate constructor service registrations > Opt in to constructor bindings, compose them separately from Arc artifacts, and preserve lazy activation and resource ownership. When service constructors already describe your dependencies, you can generate their registrations instead of repeating factory closures. The experimental `arc-gen -bindings-config` option adds `RegisterServices` alongside the existing artifact adapters. Plain constructors, manual registration closures, and `ArcBindings` callbacks remain supported; using a container is optional. ## Before you start * Use Go 1.26 or later and the [source-installed generator](/arc/go/backend/go/generation/#run-the-tool-from-source). Pin its source revision. The tools module and compiled consumer fixture use Arc.Go `v0.0.0-20261003142617-78ebbf8fdff8` (the immutable `78ebbf8` revision) and Fundamentals.Go `v0.2.0`, without a workspace or local replacement. The Arc version is a pseudo-version, not a release tag. * Select the main-module packages containing your declarations and constructors. Choose **one** of those packages as the service-registration output owner. * Keep composition code that calls generated symbols outside the selected packages. Analysis hides old generated output so stale declarations cannot prevent regeneration; handwritten inputs must compile without it. The excerpts below come from the external consumer fixture in `tools/internal/artifacts/testdata/servicebindings`. They are not a standalone application: the [declarations](https://github.com/Cratis/Arc.Go/blob/main/tools/internal/artifacts/testdata/servicebindings/input.go.txt) include the counters, other services, and command/query types used by the [composition and lifetime tests](https://github.com/Cratis/Arc.Go/blob/main/tools/internal/artifacts/testdata/servicebindings/contract_test.go.txt). The example module’s import path is `example.test/consumer`. ## 1. Keep constructors ordinary This declaration excerpt supplies a scoped concrete service and an interface: ```go //cratis:scoped type Foo struct{ Closes int } func NewFoo() *Foo { Calls++; return &Foo{} } func (f *Foo) Close() error { f.Closes++; return nil } func (*Foo) Name() string { return "foo" } type IFoo interface { Name() string Close() error } ``` With constructor selection omitted, the shared planner finds package-level `NewX` functions associated with a local named `X`. It scans only your selected packages, never recursively discovers constructors through imports, and never runs package initialization or application code. There is no richest-constructor selection or automatic pointer conversion. Multiple selected constructors for one exact key are an error. A constructor returns a named service `T` or `*T`, optionally followed by the predeclared `error`. It may take one leading `context.Context`; remaining parameters are exact dependency keys. Generic functions, open types, variadics, pointer-to-interface results, and constructors for marked command DTOs are rejected. Commands continue to be decoded request models. Lifetimes default to transient. Put `//cratis:singleton`, `//cratis:scoped`, or `//cratis:ignore-convention` on the named type’s doc comment, not its constructor. Grouped declarations need per-type comments. These directives belong to Fundamentals’ shared `bindingtypes.ReadDirectives` and `Analyze` planner, not a new Arc annotation family. Unknown, misplaced, repeated, or conflicting directives fail generation. Ignoring a service convention does not disable Arc artifact discovery. ## 2. Declare ownership and existing services Save the fixture’s [bindings configuration](https://github.com/Cratis/Arc.Go/blob/main/tools/internal/artifacts/testdata/servicebindings/bindings.json) as `/work/consumer/bindings.json`, with the full fixture declarations in that module: ```json { "formatVersion": 1, "package": "example.test/consumer", "interfaces": [ {"service": "IFoo", "implementation": "*Foo"}, {"service": "ISingleton", "implementation": "*Singleton"}, {"service": "ITick", "implementation": "*Tick"} ], "existing": [ {"key": "*Dependency", "lifetime": "singleton"}, {"key": "*External", "lifetime": "singleton"} ], "requireAllDependencies": true } ``` The explicit interface pairs select exact concrete keys. Alternatively, `matchIFoo: true` enables same-package `IFoo`/`Foo` matching. It is off by default, and competing structural implementations are diagnosed even if a competitor has no constructor. Explicit pairs avoid guessing which implementation you intend. Every `existing` entry is your attestation that composition supplies that exact key with its **actual** lifetime. Register those values before calling `RegisterServices`. If the registrar implements `di.Catalog`, generated code checks presence without resolving anything. Catalog does not inspect lifetimes; without it, your composition must guarantee presence itself. An attestation does not create a service or transfer its ownership. For a second generator or manual registration to own a key, list that key and lifetime in `existing`. If Arc’s plan also selects it, set `"duplicates": "keepExisting"`; only explicitly listed keys are retained, and Arc emits no registration for them. Register the owner first. Chronicle generator integration is outside this one-owner composition workflow. Unlisted overlaps and registrar errors still fail. ## 3. Generate and check From the Arc.Go checkout’s `tools` directory, with `/work/consumer` prepared as above, run: ```sh GOWORK=off GOTOOLCHAIN=local go run ./cmd/arc-gen -dir /work/consumer -bindings-config /work/consumer/bindings.json . GOWORK=off GOTOOLCHAIN=local go run ./cmd/arc-gen -dir /work/consumer -bindings-config /work/consumer/bindings.json -check . ``` Flags precede package patterns. Replace `.` with the complete selected package scope when your constructors span packages; the configured owner must be among those packages. Dependencies may be referenced through compiler imports, but a foreign constructor itself must belong to a selected package. Successful generation reports service bindings published and explicitly retained, with the owner’s import path. The owner receives `RegisterServices` in `zz_arc_generated.go`; artifact packages retain `RegisterArtifacts` and `ArcBindings`. A service-only owner also emits real registrations. `-check` reports verified bindings only when output matches, and never writes or repairs files. Commit the generated files with their inputs. ## 4. Compose services separately from artifacts This composition excerpt adapts the fixture’s test setup. It belongs outside the selected package and assumes a test’s `t *testing.T`, imports of `context`, `example.test/consumer` as `consumer`, `github.com/cratis/arc.go` as `arc`, and Fundamentals’ `dependencyinjection` as `di` and `dependencyinjection/container`. ```go r := &container.Registry{} if err := di.BindValue(r, &consumer.Dependency{}); err != nil { t.Fatal(err) } external := &consumer.External{} if err := di.BindValue(r, external); err != nil { t.Fatal(err) } if err := consumer.RegisterServices(r); err != nil { t.Fatal(err) } p, err := r.Build() if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := p.Close(context.Background()); err != nil { t.Error(err) } }) builder, err := arc.NewBuilder(arc.Options{ScopeFactory: p}) if err != nil { t.Fatal(err) } if err := consumer.RegisterArtifacts(builder); err != nil { t.Fatal(err) } app, err := builder.Build() if err != nil { t.Fatal(err) } ``` The excerpt stops at composition; use `app` in the rest of your test and shut it down before the provider cleanup runs. Your host owns `app.Start`, `app.Shutdown`, and `p.Close`; close the provider after application shutdown has joined work, and check cleanup errors. `di.BindValue` supplies borrowed singletons: the application still owns `external` and any other external resources and their cleanup. Generation, both registration functions, provider Build, and application Build leave constructors inactive. Provide resolves its services only when preparation runs; Handle resolves its services only after preparation accepts. Denied work, validate-only commands, a stopping Provide, and failed query argument binding do not activate the later business factories. Policies and validators may resolve their own stage dependencies; this is not a promise that all authorization or validation work is dependency-free. You can still supply `ArcBindings` callbacks for individual artifact dependencies, or use callbacks exclusively without registering constructor services. Callbacks remain stage-local and borrow their returned values. Constructor graph edges do not become every command’s dependency manifest. See [artifact registration](/arc/go/backend/go/generation/#register-the-package-once) for callback ownership and concurrency requirements. Constructor results are owned by the provider. Interface forwarders borrow the concrete result and use its effective lifetime, avoiding a second close for the same instance. The generator adds no caches, close loops, or lifecycle workers. It preserves the construction context and actual result returned alongside an error, so failed owned results can be disposed. Dependency failure before the constructor runs must not dispose a nonexistent zero-valued result: one through four dependency arguments use the safe `BindFunc1`–`BindFunc4` adapters; zero uses `di.Bind`. Larger constructors resolve ordered arguments separately and declare unique direct edges, but disposable **value** results above arity four are rejected. Repeated transient arguments remain separate resolutions. Check every registration error and discard partial registry/builder composition on failure. Registration is not transactional or idempotent. The container’s Build remains authoritative for missing edges, cycles, and singleton-to-scoped captures; generator success is not a substitute for building the provider. ## Configuration reference The bindings file is separate from the application/TypeScript `-config` profile. Without `-bindings-config`, constructor generation is disabled and ordinary adapter bytes and callbacks are unchanged. | Field | Default | Contract | | --- | --- | --- | | `formatVersion` | Required | `1` | | `package` | Required | One exact selected main-module import path; output owner | | `constructors` | Omitted | Discover exact `NewX` functions in selected packages; `[]` selects none; a nonempty list selects exact function references; `null` is invalid | | `matchIFoo` | `false` | Opt in to same-package interface naming convention | | `interfaces` | Empty | Pairs of `service` interface and exact `implementation` key, including pointer shape | | `existing` | Empty | Entries require `key` and actual `lifetime`: `singleton`, `scoped`, or `transient` | | `duplicates` | `reject` | `keepExisting` retains only explicitly attested overlaps; it is not a general try-add policy | | `requireAllDependencies` | `false` | Promote missing non-scalar dependency obligations from information to errors; scalar configuration always requires explicit provider attestation | References are local `Name` or fully qualified `import/path.Name`, with an optional `*` on type keys. Qualified names address selected packages and their direct compiler imports; configuration does not load extra packages. Aliases preserve exact Go identity. Name a closed generic or composite key through an accessible alias, as the foreign-constructor fixture does with `type Key = Box[[]*Dependency]`; do not put that expression in JSON. Foreign signatures must be accessible from the output owner. Bare predeclared type keys such as `string` are accepted for explicit configuration providers. Unknown or duplicate JSON members, trailing data, invalid references, and unsupported versions fail. Shared planner diagnostics retain their `BT` codes: for example, `BT011` identifies a missing dependency obligation, `BT012` missing scalar configuration, and `BT013` an explicitly retained registration. ## Resolve generation failures without losing output All selected packages are analyzed and rendered before publication. Preflight checks generated symbol collisions (`ArcBindings`, `RegisterArtifacts`, and `RegisterServices`) and the complete generated service import set, including aliases, nested generic arguments, existing keys, and forwarders. Direct or transitive import cycles, importing a `main` package, and Go `internal` visibility violations fail before any output changes. Fix the declarations or selection and rerun the same command. Services use the existing `zz_arc_generated.go` writer and ownership rules, with no second service manifest. Removing the opt-in removes selected stale owned service-only output. Unselected or foreign-owned output is left alone. Mixed Go/TypeScript publication requires manifest ownership and never adopts legacy adapter files, even if their marked bytes match. Keep one writer per output scope and follow [ownership and recovery](/arc/go/backend/go/generation/typescript/#ownership-and-recovery) for interrupted mixed publication. Adapter-only writes are atomic per file, not across packages. This opt-in covers constructor authoring, not implicit application-wide discovery, standalone policy/validator discovery, or operation-adapter generation. Those broader surfaces remain partial. The internal wire-contract graph is also not OpenAPI document publication: Generate still refuses OpenAPI output. Consult the [parity map](/arc/go/parity/#generation-ledger) for the supported boundaries. # Publish an OpenAPI document > Write a deterministic OpenAPI 3.1 file for your commands and snapshot queries with arc-gen, check it in CI, and understand what it describes. API consumers outside your frontend still need a contract: a partner team, a gateway, or a contract test. Writing one by hand drifts the first time a command gains a field. `arc-gen` can publish an **OpenAPI 3.1.1** document from the same analysis that produces your Go adapters and TypeScript proxies, so the document changes in the same commit as the code it describes. The document is a **file only**. Arc.Go never serves it over HTTP and has no embedded Swagger UI, Scalar or Redoc viewer. Host it yourself if you want one. ## Declare the profile OpenAPI needs a `formatVersion` 2 profile, because the document states assertions about your server that analysis cannot infer. Save this as `arc-gen.json`: ```json { "formatVersion": 2, "name": "Tasks", "defaultNamespace": "Tasks", "openapi": { "title": "Tasks API", "version": "1.0.0", "out": "api/openapi.json" }, "server": { "runtime": "arc-go" }, "responseFields": { "Cratis.ValidationResult": { "state": { "absent": true } } } } ``` * `openapi.title` and `openapi.version` become the document’s `info`. * `openapi.out` is a `.json` file relative to the module root. The `-openapi-out` flag overrides it; an absolute flag value must name a file inside the module. * `server` and `responseFields` assert how your host is configured. The current profile admits the builtin `arc-go` runtime without custom authentication, authorization policies or identity details, and validation results without a `state` member. ## Generate and check From the application module: ```bash GOWORK=off GOTOOLCHAIN=local arc-gen -config arc-gen.json ./features/... GOWORK=off GOTOOLCHAIN=local arc-gen -config arc-gen.json -check ./features/... ``` The first command writes `api/openapi.json` and reports it as published. The second writes nothing and fails when the committed document no longer matches your source, so you can run it in CI. The output is byte-identical for the same inputs regardless of package order. `arc-gen` owns the file through a manifest. Without TypeScript output, the manifest is `.arc-gen-manifest.json` in the module root; with [TypeScript proxies](/arc/go/backend/go/generation/typescript/) the document joins the manifest in the TypeScript output root. Commit the manifest with the document. Both modes refuse to overwrite a file the manifest does not own or a generated file you edited by hand. When you remove `openapi` from a configured profile, the next run deletes the previously owned document. Switching between the two manifest locations is refused rather than adopted: move the existing document aside and regenerate. ## What the document describes | Arc surface | OpenAPI representation | | --- | --- | | Command execute and validate routes | `post` operations with a required JSON body and the command or validation result envelope | | Snapshot query (GET) | `get` and `head` operations; HEAD has no response body | | Snapshot query (HTTP QUERY) | `x-cratis-query` path-item extension holding the operation, with a JSON request body | | Query arguments | GET query parameters and members of the QUERY body’s `arguments` object | | Pageable results (`queries.Page[T]`) | `page`, `pageSize`, `sortBy` and `sortDirection` GET parameters and the QUERY body’s `paging` and `sorting` objects | | Models and enums | `components/schemas`, separately for input and output direction | Each operation lists the 200, 400, 403, 413, 500 and 503 responses, plus 415 for bodies, the correlation header and, for commands, `X-Allowed-Severity`. Integer bounds are exact, including 64-bit values. ### Arguments Arguments come from the query’s argument struct and its `query` tags. Required arguments (`query:"required"`) are required GET parameters. Missing, empty and null values count as missing; `query:"default=…"` arguments bind their default. Collection arguments use comma-separated form encoding (`style: form`, `explode: false`), so elements cannot contain commas. The reader matches names case-insensitively. In the QUERY body, each argument accepts its JSON type or a text form parsed like the GET value. Because names are case-insensitive, required arguments are listed in `x-cratis-required-arguments` rather than a JSON Schema `required` list. ### QUERY is an extension OpenAPI 3.1 has no QUERY method. The operation sits under the path item’s `x-cratis-query` member and is never presented as GET or POST. Standard OpenAPI 3.1 tools see and route the GET and HEAD operations and ignore the extension; only a Cratis-aware consumer can invoke QUERY from the document. Schemas describe values, not tokens JSON Schema validates values. The reader also rejects some inputs a schema accepts: duplicate (including case-variant) member names, trailing JSON, and decimal or exponent spellings of integers such as `1.0`. Operation descriptions state these boundaries. ## Unsupported shapes refuse the whole document `arc-gen` writes no document rather than an approximate one. Generation fails with the reason when the selected packages contain any of these: * Observable queries and other streams (document them separately). * Authorization declarations, roles, discovery exclusions, custom block severities or portable validation rules. * Field validation rules, opaque or declared codecs, UUID and date codecs, floating-point values (their codec admits special values), derived or polymorphic models. * Query arguments that are models, maps, presence-preserving (`query:"preservePresence"`), validated, or use a reserved control name. * Sortable read-model fields on a query whose result is not pageable. * Graph diagnostics, `openapi.includeFrameworkEndpoints`, `openapi.streaming` set to `metadata`, or `openapi.servers` other than the relative `/` root. ## Deliberately not implemented These are design decisions, not gaps waiting for a fix: * **No HTTP exposure.** Arc.Go has no counterpart to C# `MapOpenApi()`; the document is not served at runtime and never changes with deployment state. * **No embedded explorer.** There is no Swagger UI, Scalar or Redoc hosting. Serve the committed file with a viewer of your choice, behind your own access controls. * **No security schemes.** Operations declare `security: []` and the document reports `securityVerified: false`. A document is never authorization. ## Compared with C# Arc C# Arc’s `MapOpenApi()` serves an OpenAPI 3.0 route catalog without body schemas, and `Cratis.Arc.OpenApi` adds ASP.NET Core transformers. Arc.Go instead generates an OpenAPI 3.1 file at build time with request and response schemas, and refuses shapes it cannot describe. Response envelopes follow the C# HTTP contract; the document itself is not a byte-for-byte port of either C# document. ## See also * [Generate adapters and proxies](/arc/go/backend/go/generation/) installs `arc-gen`. * [TypeScript proxies](/arc/go/backend/go/generation/typescript/) shares the profile and manifest. * [Export Screenplay metadata](/arc/go/backend/go/generation/screenplay/) uses the same graph. # Generator directives and signatures > Supported arc-gen declarations, method shapes, dependency stages, and diagnostics. `arc-gen` is experimental tooling for typed command, snapshot-query, and declared observable-source adapters and a bounded [TypeScript proxy profile](/arc/go/backend/go/generation/typescript/). It analyzes explicitly selected packages with `go/packages` and `go/types`; it never executes package initialization, constructors, or business methods. Start with [generating model-bound adapters](/arc/go/backend/go/generation/) for the authoring workflow. ## Command-line options | Input | Default | Meaning | | --- | --- | --- | | Package patterns | `.` | Packages in the current module; dependency modules are rejected | | `-dir` | Current directory | Directory for Go package loading | | `-tags` | Empty | Comma-separated build tags, passed to the Go loader | | `-check` | False | Return an error for missing, changed, or obsolete output without writing or recovery | | `-config` | None | Strict versioned JSON application profile; internal wire-contract analysis does not enable OpenAPI publication | | `-bindings-config` | Disabled | Separate strict version-1 JSON [constructor-service configuration](/arc/go/backend/go/generation/constructor-services/#configuration-reference) | | `-typescript-out` | Disabled | Override profile `typescript.out` with an output root relative to the selected module | | `-emit-go` | True | Explicitly supplied value overrides profile `typescript.emitGo`; false is unsupported for mixed generation | The Go environment also selects the build configuration. Generate and compile with the same tags, GOOS, and GOARCH. The single output file represents that configuration, not every possible artifact set. Regenerate when switching configurations, or separate incompatible artifact sets into different packages. Do not run concurrent generators against the same output paths. ## Declaration directives Put directives in line comments attached to the declaration’s documentation. Options are whitespace-separated `key=value` tokens; values cannot contain whitespace or quoted/escaped whitespace. A comma separates role alternatives. Block-comment directives are rejected. | Declaration | Directive | Options | | --- | --- | --- | | Package documentation | `//arc:namespace Shop.Inventory` | One logical namespace per package | | Named command struct | `//arc:command` | `name`, `path`, `block-on` | | Named read-model struct | `//arc:readmodel` | `name`, `path` | | Query method or function | `//arc:query` | `model`, `name`, `path`, `http` | | Artifact or query | `//arc:authorize` | Optional `roles=Editor,Admin` and `policy=CanWrite` | | Artifact or query | `//arc:allow-anonymous` | No options | | Artifact or query | `//arc:exclude-from-discovery` | No options | | Type, method, or function | `//arc:ignore` | No options; cannot combine with other directives | Names default to the Go type or method/function name. Namespace precedence is package directive, builder namespace, then global. Import paths and filesystem folders never determine public identity. Routes use Arc’s existing metadata algorithm. `path=` on a query explicitly disables a model-level path override. `http` accepts only `GET` or `QUERY`. `block-on` accepts `unknown`, `information`, `warning`, or `error`, matching the runtime’s inclusive validation floor. Unknown directives, misspelled options, duplicate singular directives/options, invalid metadata, and misplaced directives fail generation with file, line, and column diagnostics. ## Authorization and field metadata Repeated `authorize` directives are AND requirements. Roles inside one directive are OR alternatives. Bare `authorize` requires authentication. A query-level declaration replaces model-level authorization; it does not merge with it. Anonymous plus restrictions at the same declaration level is an error. Named schemes are unsupported and diagnosed, never ignored. Excluding a command or read model from discovery does not disable execution or protect its endpoints. Read-model exclusion applies to all its generated queries. Policy existence and dynamic registration compatibility remain builder checks. Runtime field tags retain their existing behavior: `json`, `query`, `validate`, `arc:"key"`, and `arc:"identity"`. Generated registrations use the public metadata and binding APIs rather than another field-binding implementation. Do not combine artifact directives with blank-field `arc` or `authorize` declaration metadata; the generator rejects that ambiguity. Other directive families are untouched. ## Command methods A command is a defined, nongeneric struct with a directly declared `Handle`. Promoted methods are not discovered. The decoded command is the receiver; the adapter never resolves a command instance through DI. Ignoring Provide omits that stage. Ignoring Handle leaves an invalid command and is diagnosed. `arc:ignore` cannot disable a runtime Validate convention; use manual registration options when deliberately opting out of model validation. | Method | Supported results | Parameter rules | | --- | --- | --- | | `Handle` | `(O, error)` or `error` | Optional context and command context; remaining parameters are exact dependencies or the provided payload | | `Provide` | `(P, error)`, `(commands.Preparation[P], error)`, or a supported control plus `error` | Optional context and command context; other parameters resolve only during preparation | | `Validate` | `([]validation.Result, error)` | Exactly `context.Context`; invoked by runtime model validation | `context.Context` and `commands.CommandContext` are supplied by the invocation, not DI. Each infrastructure type may appear at most once. Their positions do not change the dependency stage. Handle’s receiver determines pointer or value registration. A pointer-only Provide or Validate cannot accompany a value-registered Handle. The generator diagnoses incompatible Validate signatures rather than silently skipping them. A provided payload must match exactly one Handle parameter by Go type identity. Zero matches or repeated matches are errors. Use a struct for multiple prepared values. Typed nil payloads remain payloads; there is no assignability search, null dropping, or fallback to DI for a missing preparation value. Supported control-only Provide results are `validation.Result`, `[]validation.Result`, `authorization.Decision`, and `commands.Result[commands.NoResponse]`. The generated adapter uses an internal empty payload and preserves the preparation-stage severity rules. Accepted controls continue to Handle; `commands.StopProviding[P]` explicitly stops even on success. Validation collections and command-result DTOs returned by Handle remain ordinary values under the runtime return contract. Provide and Handle dependencies have separate static manifests. Default resolution uses `execution.Resolve[T]` only inside its callback. A pointer service is still required; pointer syntax does not imply an optional DI registration. ## Read-model queries The primary convention is an exported method with an unnamed or blank **value** receiver on an `arc:readmodel` struct. It must return `(O, error)`, where `O` is the owning model, its pointer, a slice/array of either, or `queries.Page[M]` or `queries.Page[*M]`. Value `queries.ObservedCollection` uses collection data rather than exposing its change hints. Recognized observable declarations are `observable.Source[O]`, `observable.CurrentSource[O]`, `*observable.State[O]`, and `*observable.Subject[O]`, including aliases. `O` must have a supported owning-model shape above. Pointer Page/ObservedCollection wrappers, nested sources, foreign models, named collection definitions, and opaque provider emissions are rejected. The generated `queries.RegisterObservable` performer retains the declared source’s CurrentSource capability through ordinary interface assignment; it does not open streams or add `WithEnumerable` just because emissions are collections. Unsupported emissions in recognized sources diagnose rather than disappearing from discovery. Unrelated return shapes and named/pointer receiver methods are not implicitly discovered. Explicit `arc:query` on a stateful or pointer receiver is an error. Use `arc:query` to select a private namespace method. A package-level query must name a local opted-in read model through `model=GoTypeName`. Duplicate public model/query identities fail instead of selecting a winner. `context.Context`, `queries.QueryContext`, and `queries.Parameters` come from the invocation. The first other parameter must be a named, nonpointer argument struct. All subsequent parameters are exact service dependencies. With no caller input, use `queries.NoArguments` before service parameters. If there are no ordinary parameters, the adapter supplies a no-arguments registration automatically. ## Output and recovery The following describes adapter-only generation. TypeScript-enabled invocations use [manifest ownership and recovery](/arc/go/backend/go/generation/typescript/#ownership-and-recovery) across the complete Go/TypeScript plan instead; they never adopt legacy adapter files. Each selected artifact package receives `zz_arc_generated.go` with a generator version and format-version header, an `ArcBindings` type, and a `RegisterArtifacts` function. No `init` registration or global registry is emitted. `RegisterArtifacts` accepts one builder and zero or one binding sets. It stops at the first registration error; registration is not transactional across artifacts. Analysis overlays existing owned output with a package-only stub. This allows clean bootstrap and recovery from stale methods, imports, and generated syntax. Keep composition code that references generated symbols outside the selected artifact packages. Handwritten inputs must type-check without generated symbols. The generator validates all selected packages before writing. Each file replacement is atomic, but a multi-package write is not a filesystem transaction. Any write failure returns an error. After repairing the cause, rerun generation and `-check`. An owned output is removed when its selected package no longer needs generated artifact or opted-in service output. Files without the generator’s ownership header, symlink outputs, and unselected packages are never overwritten or cleaned up. Variadics, unresolved generics, arbitrary multiple returns, channels, opaque provider-specific source emissions, implicit service discovery, standalone validator/policy discovery, operations, and proxy-only output are unsupported. Explicitly opted-in [constructor service generation](/arc/go/backend/go/generation/constructor-services/) uses the shared Fundamentals planner and a separate `RegisterServices` entry point. Custom opaque MongoDB `Source[Find]` remains a manual-runtime surface until explicit emission metadata exists; no provider renderer or broader C# nullable-layout support is inferred. Supported TypeScript models, commands, snapshot queries, and observable queries, including stricter collection identity/nullability limits, are described in the [proxy profile](/arc/go/backend/go/generation/typescript/). Existing manually registered model/concept validators remain supported. A read-model declaration does not register a Chronicle projection. # Export Screenplay metadata > Write a partial Screenplay .play document describing your commands, read models and queries with arc-gen, and understand what it leaves out. A [Screenplay](/arc/backend/csharp/generating-a-screenplay/) document lets the model of your application live as text next to its code, so a pull request that adds a command also shows the model change. `arc-gen` can export the part of that model it knows from your Go declarations: command inputs, read models and their snapshot and observable queries. The export is **partial metadata**, not an event model. Go analysis has no event, projection, reactor or slice descriptors yet, so the document says what it can and lists what it cannot as comments at the top of the file. ## Generate the document The export needs a `formatVersion` 2 profile, which carries the exact Go scalar types that distinguish Screenplay `Int` from `Decimal` and `DateTime` from a string. The profile `name` becomes the Screenplay `domain`, so it must be an ASCII identifier: ```json { "formatVersion": 2, "name": "Tasks", "defaultNamespace": "Tasks", "screenplay": { "out": "docs/model.play" } } ``` From the application module: ```bash GOWORK=off GOTOOLCHAIN=local arc-gen -config arc-gen.json ./features/... GOWORK=off GOTOOLCHAIN=local arc-gen -config arc-gen.json -check ./features/... ``` `screenplay.out` is a `.play` file relative to the module root; the `-screenplay-out` flag sets or overrides it. The same input always produces the same bytes, regardless of package order, so `-check` can fail CI when the committed document drifts from source. Enabling Screenplay, including through `-screenplay-out` alone, also enables wire analysis and endpoint verification in generated Go adapters. Like OpenAPI and TypeScript output, it requires a static namespace from source or the profile. Adding or dropping Screenplay as the only wire consumer changes endpoint-bearing adapters, not just the `.play` file. Pass the same profile, package scope, build tags and output flags to generation and `-check`; a check without the `-screenplay-out` flag used to generate the adapters reports them as stale. Changing only the output file location does not change endpoint verification. Ownership works as for the [OpenAPI document](/arc/go/backend/go/generation/openapi/): a manifest in the module root (or the TypeScript output root when you also publish [TypeScript proxies](/arc/go/backend/go/generation/typescript/)) records the file. `arc-gen` refuses to overwrite an unowned or hand-edited document, and deletes the owned document when the same profile and package/build scope stops requesting it. Unrelated adapter-only invocations leave that artifact manifest and its files alone. The file starts with the standard `// Code generated by arc-gen … DO NOT EDIT.` line. ## What the document contains For a `Register` command and a `Listing` read model with an `All` query that takes a `Prefix` argument and returns `queries.Page[Listing]`, the body after the header comments is: ```text domain Tasks type Listing @id Int @label String type Register @count Int @name String module Application feature Metadata slice StateChange CommandRegister command Register @count Int @name String slice StateView ReadModelListing readmodel Listing @id Int @label String query All => Listing[] filter prefix String ``` * Each command becomes a `StateChange` slice with its input properties. * Each read model becomes a `StateView` slice with its queries. Observable queries return `observable` types; query arguments become `filter` lines. * The `Application` module and `Metadata` feature are presentation grouping only. Go packages and HTTP routes are not treated as domain modules. The grammar targets Cratis.Screenplay 4.48.1, the version pinned by C# Arc. ## What the document leaves out Comments at the top of the file name every omission (`SPG001` to `SPG104`), for example: * Events, command event production, projections, reducers, reactions, constraints, specifications and screens are not in the analysis graph. Their absence from the document does not mean your application lacks them. * Authorization, validation rules, defaults, paging, sorting and command response values are omitted, and the affected artifact is named. * HTTP routes, binding and codecs are not a Screenplay contract. Shapes Screenplay cannot express refuse the whole export with an error naming the artifact: maps, enums, concepts, opaque codecs, nested collections, nullable collection elements, empty models, commands without input, colliding type names and graph diagnostics. Nothing is renamed or flattened to make it fit. ## Deliberately not implemented C# Arc can embed generated documents and serve a read-only event-model explorer at `/.cratis/event-model`. Arc.Go does not: there is **no embedded viewer and no HTTP route** for the document. Open the committed `.play` file with the Cratis CLI or Screenplay tooling instead. The export also never starts your application, connects to Chronicle or reads stored events. ## See also * [Generate adapters and proxies](/arc/go/backend/go/generation/) installs `arc-gen`. * [Publish an OpenAPI document](/arc/go/backend/go/generation/openapi/) uses the same graph and ownership. # Generate TypeScript proxies > Publish supported models, commands, snapshot queries, and observable queries alongside their Go adapters, and check owned output safely. Keep your frontend contract aligned with your Go declarations instead of copying request and result types. The experimental `arc-gen` application profile generates models, numeric enums, commands, **snapshot queries**, and declared **observable queries** together with their Go adapters. Compatibility is Partial: the tested client versions are Arc/Arc.React 22.48.2 and Fundamentals 7.22.0. Proxy-only output is unsupported. Generated observable hooks have bounded mounted Node evidence; browser and complete React parity remain unverified. ## Select the application profile First [install the pinned source tool and declare artifacts](/arc/go/backend/go/generation/). Select the complete application artifact scope, not a composition package that refers to generated symbols. Handwritten artifact source must compile without those symbols. Use fresh output destinations: adapters created by adapter-only generation do not have mixed-output ownership and cannot be adopted automatically. Save this illustrative profile as `arc-gen.json` in an application whose artifact packages live under `features/`: ```json { "formatVersion": 1, "name": "shop-frontend", "defaultNamespace": "Shop", "routes": { "routePrefix": "/api", "segmentsToSkip": 1 }, "typescript": { "out": "web/src/api", "clientVersion": "22.48.2", "proxyFileSuffix": false } } ``` Static namespaces supply frontend identities. Add `packageNamespaces` mappings when reachable dependency models have no source namespace. Dependency models can receive TypeScript output, but dependency packages never receive Go adapters. From the application module: ```bash GOWORK=off GOTOOLCHAIN=local arc-gen -config arc-gen.json ./features/... GOWORK=off GOTOOLCHAIN=local arc-gen -config arc-gen.json -check ./features/... ``` Successful generation reports the adapter and TypeScript file counts, profile, and contract fingerprint. The output root resolves relative to your module and contains a manifest, type files, and sorted per-directory barrels. Commit generated files and the manifest with their inputs. `-check` compares inventory and bytes without writing, deleting, creating directories, or repairing interrupted output. Call each selected package’s generated `RegisterArtifacts(builder)` before `Build`, and configure the builder with the profile’s route options. Generated adapters register endpoint expectations through `Builder.ExpectGeneratedEndpoints`; route disagreement, including drift caused by additional manual artifacts, fails `Build`. Frontend deployment base paths remain runtime settings, not generator route prefixes. Roles on proxies are hints, never server authorization. ## Supported shapes and explicit limits The generator analyzes the whole selected catalog, resolves routes through `metadata.Resolve`, and renders the complete plan before publication. Discovery- hidden and frontend-excluded artifacts still participate in route resolution. Application initialization, codecs, validators, and business methods never execute for discovery. * Models use class-based field metadata and the shared scalar/concept classifier. Nested models, numeric enums, supported declared derivatives, and rich Guid/date constructors retain their wire identities. * Commands support typed scalar/model/list responses and the portable rules `notNull`, `notEmpty`, length bounds, and numeric comparison bounds. Mixed generation registers `validation.NewPortable` on the server too; independent server rejection is not replaced by client validation. * Model-owned single, pointer, slice, array, and `queries.Page` snapshots become model or model-array payloads. Plain lists do not acquire synthetic paging. GET/HTTP QUERY preference must agree with the exposed endpoint methods. * Observable queries declare `observable.Source[O]`, `CurrentSource[O]`, `*State[O]`, or `*Subject[O]`, including aliases. Their emissions use the same owning-model single/pointer/list/array shapes, value `queries.Page[M]`, or value `queries.ObservedCollection[M]`. Source wrappers and change hints are not client data. Generated `ObservableQueryFor` classes expose subscription, snapshot perform, sorting, paging, and hook helpers through the pinned client runtime; they do not generate a transport or delta algorithm. * Observable collection proxies accept either one unambiguous direct Go `ID`/`Id` serialized as `id`, with a supported nonnullable scalar and no omission tag, or no identity at all (C# JSON fallback). A serialized `id` without `ID`/`Id`, and competing, JSON-hidden or embedded conventional members, fail before publication. An identity tag is not a custom client delta extractor. * Primitive query defaults validate the original Go grammar and target width, but remain server defaults rather than initialized client values. Nullable collection elements and rich defaults are unsupported. Snapshot query string rules support `notNull`, `notEmpty`, `minLength`, `maxLength`, and `length`, with matching server and client validators. Observable-query validation and unproved rule shapes are rejected. Typed server validators and named policies use [validator and policy directives](/arc/go/backend/go/generation/validators-and-policies/); these do not translate arbitrary application code into client validation. * Sorting helpers use declared sortable result-wire fields, not query arguments, per [Arc#2998](https://github.com/Cratis/Arc/issues/2998). They do not add backend provider paging or sorting. Unsupported interfaces, opaque codecs, dynamic responses, rule forms, derived providers, rich dictionaries, eager constructor cycles, unsafe names/layouts, and unsupported source/channel/provider results fail instead of producing `any` or a partial successful plan. Custom opaque `Source[Find]` MongoDB generation is unsupported until explicit emission metadata exists, even when manual runtime registration works. This subset does not cover every C# rich nullable or provider layout. Custom imports, interface output, source grouping, and library mode are unsupported. `-emit-go=false` fails because proxy-only runtime contract verification is not implemented. Query keys containing regex metacharacters are rejected for the pinned Arc helper ([Arc#3014](https://github.com/Cratis/Arc/issues/3014)). JavaScript numeric and temporal precision limits remain. The pinned Fundamentals serializer rejects null-valued field serialization; correct null hydration is not a claimed null round-trip fix ([Fundamentals#1148](https://github.com/Cratis/Fundamentals/issues/1148)). Enumerable command response annotations retain the pinned C# element generic even though runtime responses are arrays. Derived classes must be loaded, normally through their generated directory barrel, for runtime discriminator selection. ## Ownership and recovery TypeScript-enabled publication uses `.arc-gen-manifest.json` to bind profile owner, selected package/build-tag scope, relative paths, fingerprint, and content hashes. Use separate roots for different scopes. Existing files must match both manifest ownership and hashes/markers. Even byte-identical marked files, barrels, and legacy adapters are rejected without ownership. Preserve and move reviewed files or use fresh consumer/output roots; there is no migration bypass. Preflight checks the complete physical path graph, including both roots, metadata files, active outputs, stale entries, and pending recovery. File/directory, case, symlink, traversal, and unsafe device-name collisions fail before mutation. Stale deletion requires matching ownership, marker, and hash. User files and directories are never blanket-deleted. Unchanged files retain their modification times; missing active files can be regenerated. Publication is atomic per file, **not** a transaction across directories. The `.arc-gen-pending.json` journal records old/new manifests and changed bytes before mutation. A write, rename, delete, or manifest failure returns failure and preserves that journal. Fix the cause and rerun the original invocation with unchanged inputs to validate live bytes, roll back the interrupted set, and publish the full plan. Intervening edits stop recovery without overwriting evidence; an existing empty file is not equivalent to absence. Preserve the journal and edits, then restore the recorded owned bytes before rerunning. Never delete the journal to hide a failed publication. Check mode rejects pending recovery without repairing it. Use a trusted single-writer workspace. Recovery covers process interruption, not power-loss durability. ## Executable compatibility evidence The production fixture runs the actual CLI, compiles the generated Go adapters against a fetchable runtime pin, and strictly compiles emitted TypeScript against locked real declarations. Its joined loopback host exercises command validation and execution, GET/HTTP QUERY snapshots, complete envelopes, rich hydration, and independent server rejection. Publication tests cover failed writes/renames/deletes, recovery edits, empty-versus-absent files, full path preflight, and unmanifested ownership rejection. Observable production tests generate mixed families and verify exact output with `-check`; independent Go consumers exercise lazy factories, argument/authorization admission, current/pending snapshots, failures, cancellation, and join-before- disposal. Strict TypeScript compilation covers observable hook signatures with both decorator modes, not mounted hooks. The locked Node lane executes an actual production-generated model-bound query using untouched default WebSocket hub + Delta settings: shared socket, date/model hydration, exact change sets and callback counts, argument replacement, independent cancellation, terminal Unauthorized, and joined host shutdown with cumulative source counters. Its final collection uses an independent test-consumer reducer, not React. The existing manual lane separately executes the wider transport matrix. A third lane mounts generated observable hooks using real providers with explicit WebSocket hub/Delta: rich collection reconstruction, argument replacement, cache sharing/reuse and joined source/resource cleanup. It does not use the full Arc wrapper or prove browser/DOM/StrictMode, suspense, reconnect, 30-second expiry, server-side paging or setter correctness. Same-key setter retention characterizes [Arc issue 2869](https://github.com/Cratis/Arc/issues/2869) at 22.48.2, not a fix. Paired .NET hosts and full frontend parity remain unverified. The [observable fixture](https://github.com/Cratis/Arc.Go/blob/main/ContractTests/observables/README.md) and [parity map](/arc/go/parity/) record the exact limits; the [CLI reference](/arc/go/backend/go/generation/reference/) lists invocation options. # Generate validator and policy registrations > Declare typed validators and named authorization policies with arc:validator and arc:policy so RegisterArtifacts registers them. A command declares `//arc:authorize policy=Editors`, and the builder refuses to build until something registers a policy named `Editors`. The validator for that command is a small type in the same package, yet you still have to remember to call `validation.Register` in your composition code. C# Arc discovers both by type. In Go, `arc-gen` registers them for you when you mark the declaration with `//arc:validator` or `//arc:policy`. The examples on this page are excerpts from the compiled consumer that the generator’s tests run; `rename`, `code`, `ownerRules`, `auditors` and `auditLog` are ordinary application types. The generated code calls the same public registrars you would call by hand, from the package’s `RegisterArtifacts`. Nothing is discovered at run time, and the generator never constructs a validator or policy while it analyzes code. ## Declare a shared validator Mark a struct whose zero value is ready to use. Its `Validate` method names the type it validates: ```go //arc:validator type renameValidator struct{} func (renameValidator) Validate(_ context.Context, command rename) ([]validation.Result, error) { if command.Name == "" { return []validation.Result{{Severity: validation.Error, Message: "Name required", Members: []string{"name"}}}, nil } return nil, nil } ``` `RegisterArtifacts` then contains exactly the call you would write yourself: ```go validation.Register[rename](builder.Validators(), renameValidator{}) ``` The registered instance is shared and must be safe for concurrent use. When `Validate` has a pointer receiver, the generator registers `&renameValidator{}` instead. The validator applies wherever the validation graph meets the exact type, so a validator for a nested struct also runs for every command that contains it. Add `concept=true` to register an opaque concept leaf with `validation.RegisterConcept`. Its findings attach to the member that holds the concept: ```go //arc:validator concept=true type codeValidator struct{} func (*codeValidator) Validate(_ context.Context, value code) ([]validation.Result, error) { if strings.ToUpper(string(value)) != string(value) { return []validation.Result{{Severity: validation.Error, Message: "Code must be upper case"}}, nil } return nil, nil } ``` ## Declare a scoped validator with dependencies When a validator needs services, mark a package-level constructor instead of the type. Each validation calls the constructor through `validation.RegisterScoped`; its parameters are resolved like `Handle` dependencies: ```go //arc:validator func newOwnerValidator(ctx context.Context, rules ownerRules) (*ownerValidator, error) { return &ownerValidator{rules: rules}, nil } ``` A `context.Context` parameter receives the validation context. Every other parameter becomes a field on `ArcBindings`, here `ResolveOwnerRules`. A nil callback falls back to `execution.Resolve` and declares the exact dependency key, so `Build` fails early when the scope factory cannot supply it. The constructor may return `T` or `(T, error)`. A non-nil error fails that validation, and a nil instance is rejected by the runtime. `concept=true` selects `validation.RegisterScopedConcept`. ## Declare a named policy `//arc:policy name=` registers an `authorization.Policy` under the name that `//arc:authorize policy=` declarations refer to. A struct registers its zero value as a shared policy through `Policies().Register`: ```go //arc:policy name=Editors type editors struct{} func (editors) Authorize(_ context.Context, value authorization.Context) (authorization.Decision, error) { if value.Principal.HasRole("Editor") { return authorization.Allow(), nil } return authorization.Deny("editors only"), nil } ``` A constructor registers a scoped policy through `authorization.RegisterPolicy`, with the same dependency rules as a scoped validator: ```go //arc:policy name=Auditors evaluates-anonymous=true func newAuditors(log auditLog) auditors { return auditors{log: log} } ``` `evaluates-anonymous=true` sets `PolicyOptions.EvaluatesAnonymous`, so guests reach the policy instead of being denied before it runs. It defaults to `false`. ## Contract and diagnostics | Directive | Declaration | Generated registration | | --- | --- | --- | | `//arc:validator` | Struct type | `validation.Register[T]` with the zero value | | `//arc:validator concept=true` | Struct type | `validation.RegisterConcept[T]` | | `//arc:validator` | Constructor function | `validation.RegisterScoped[T]` with a factory and dependency keys | | `//arc:validator concept=true` | Constructor function | `validation.RegisterScopedConcept[T]` | | `//arc:policy name=N` | Struct type | `Policies().Register("N", value, options)` | | `//arc:policy name=N` | Constructor function | `authorization.RegisterPolicy("N", factory, options, keys...)` | The generator rejects these declarations with a file, line and column diagnostic: * A type or constructor result whose method set lacks the exact `Validate(context.Context, T) ([]validation.Result, error)` or `Authorize(context.Context, authorization.Context) (authorization.Decision, error)` method. Lookalike `context`, `validation` or `authorization` types from another package never match, and the model-bound `Validate(context.Context)` method is not a typed validator. * Generic, aliased or non-struct types, methods, and variadic or generic constructors. * Constructors that return anything other than `T` or `(T, error)`. * Validators for interfaces, functions, channels or `unsafe.Pointer`, and for types that the declaring package cannot name. * Two validators for the same type, or two policies with the same name, in one package. * `arc:policy` without `name=`, option values other than `true` or `false`, and combinations with `arc:command`, `arc:readmodel`, `arc:authorize`, `arc:allow-anonymous`, `arc:exclude-from-discovery` or `arc:ignore`. Duplicates across packages are reported by the runtime registries as `validation.ErrDuplicate` or `authorization.ErrDuplicate` when `RegisterArtifacts` runs. ## Limits * Validators and policies are registered by `RegisterArtifacts`, so a package that declares them also needs an `arc:command`, `arc:readmodel` or derived model. A package with only validators or policies is rejected rather than silently generating nothing; register those manually for now. * The generator does not check that every `//arc:authorize policy=` name has a registered policy. The builder checks that at `Build`. * C# Arc discovers validators by base type and resolves them from the service provider. Go has no assembly scanning, so the directive is the opt-in, and constructor parameters replace constructor injection. See [directives and signatures](/arc/go/backend/go/generation/reference/) for the other directives and [generating model-bound adapters](/arc/go/backend/go/generation/) for the generation workflow. # Identity details > Provide display data without changing trusted claims, roles or operation permissions. You may need profile data for a frontend without giving that data authority over a command. `identity.ProvideDetails` builds a caller-owned display View from a trusted principal and a plain details provider. The built application maps `/.cratis/me` using the default or selected provider; the standalone helper itself performs no transport work. ## Provide fresh details This excerpt assumes imports for `identity` and `context`: ```go provider := identity.DetailsProviderFunc[string]( func(_ context.Context, value identity.Context) (identity.Details[string], error) { return identity.Details[string]{ IsUserAuthorized: true, Value: "Hello, " + value.Name(), }, nil }) ``` Call `identity.ProvideDetails(ctx, provider)` after installing a verified principal. It suppresses the callback for anonymous callers (`ErrUnauthenticated`), rejects nil/typed-nil providers (`ErrInvalidProvider`), and rejects a false IsUserAuthorized (`ErrDetailsDenied`). Every call invokes freshly; there is no cookie or result cache. Callback errors propagate; cancellation is checked around the callback. Shared providers must support concurrent calls. `ContextFor(principal)` copies ordered claims and supplies `unknown` display fallbacks for empty ID/name without changing Principal. `Context.Claims()` returns independent slices. ID comes from the verified adapter’s explicit subject, not an arbitrary claim or forwarded header. ## Keep display data non-authoritative | View field | Source | | --- | --- | | `id`, `name` | Display context | | `isAuthenticated` | Trusted principal | | `isAuthorized` | Details provider’s verdict, not command/query admission | | `roles` | Copied explicit principal roles; nil emits `[]` | | `details` | Unwrapped application Value, including null | `MarshalJSON` uses Arc’s bounded traversal and camelCase model serialization. Details is borrowed; do not mutate it while encoding. A malicious details DTO with its own `roles` or `isAuthenticated` fields stays under `details` and cannot change top-level roles or trusted context metadata. View itself is mutable caller-owned output, never a principal you should install for authentication. For operation permission use [authorization policies](/arc/go/backend/go/authorization/). For the immutable trusted snapshot use [execution metadata](/arc/go/backend/go/core/execution-context/). ## Register a hosted provider `arc.RegisterIdentityDetails(builder, name, provider)` borrows a concurrently callable provider. `RegisterScopedIdentityDetails` accepts a lazy resource-scoped factory and optional declared DI keys. Anonymous callers never activate it. One custom provider is selected automatically; multiple providers require `Identity.DetailsProvider`, and unknown selections fail Build. `/.cratis/me` returns fresh unwrapped JSON, empty 401 for anonymous callers and empty 403 for provider denial. Provider/cleanup failures suppress success with empty 500. Legacy `.cratis-identity` cookies are expired, never decoded or issued. Responses use `no-store, private` and merged `Vary: Cookie`. See [schemas](/arc/go/backend/go/introspection/schemas/) and [parity](/arc/go/parity/) for bounded schema support and current-source cookie behavior. # Protect discovery endpoints > Expose command/query catalogs, identity schemas and development listings deliberately. Discovery makes an application’s callable surface inspectable without executing its commands or queries. It can also reveal private names and development users; configure its exposure separately from operation authorization. ## Exposure matrix | Configuration | Authentication capability | Result | | --- | --- | --- | | Default Development | Any | Anonymous discovery | | Default other environment | None | Discovery unmapped | | Default other environment | Registered handlers | Authenticated discovery | | RequireAuthentication true | None | Build failure | | RequireAuthentication false | Any | Anonymous; production startup warning | | Roles specified | Registered handlers | Authentication plus any listed role | | Roles plus explicit anonymous | Any | Build failure | Capability means explicitly registered authentication handlers. For embedded credential verification, register `authentication.HostPrincipal()`. Arbitrary outer middleware alone does not declare capability. Roles are exact and case-sensitive. Anonymous protected discovery is empty 401; missing roles is empty 403. Explicit credential failure remains 401 on public discovery. ## Routes and output GET and HEAD map `/.cratis/commands`, `/.cratis/queries`, `/.cratis/identity-details/schema`, `/.cratis/users` and `/.cratis/tenants` when exposure is available. `Introspection.Enabled` disables only the two catalogs. `/.cratis/me` is always mapped independently and requires an authenticated caller. Lists and schemas are unwrapped JSON, not QueryResult. Catalog routes come from the compiled table; `/validate` is not another command. Excluded artifacts remain callable and retain authorization. Catalog membership never runs operation policies. Command entries contain name, namespace, route, type, documentationSummary and payloadSchema. Query entries also contain fullyQualifiedName and argumentsSchema. Ordering is deterministic; summaries are explicit/generated metadata, empty by default. `AddUsersProvider` and `AddTenantsProvider` register lazy resource-scoped providers. Results concatenate in registration order without deduplication. No providers returns `[]`. These values are display data, never credentials or membership. Every discovery response uses `no-store, private` and merged `Vary: Cookie`. This intentionally extends C# protection to catalogs and schemas. Continue with [bounded schema support](/arc/go/backend/go/introspection/schemas/) when you expose custom model codecs. # Supply truthful discovery schemas > Understand bounded metadata-only schemas and override opaque application codecs. A schema which hides unsupported fields is worse than a startup error. Arc builds schemas from registration types without constructing providers or running codecs. Exposed unsupported shapes fail Build with `ErrSchemaUnavailable` and an artifact/type name; excluded or unavailable catalogs do not force unrelated schemas into your app. ## Supported shapes Schemas reuse Arc field visibility and wire names. Scalars, pointers, Optional, ordinary structs, arrays/slices and string-key maps are supported. Approved UUID, calendar, duration and timestamp representations retain their wire forms. Numeric enums remain numeric; there is no enum-member registry. Query requiredness comes from `Registration.Parameters`, including defaults, not dependency types. Identity schemas describe the selected details type; default empty details is `{}`. Pointers/Optionals describe nullability. Omission and input optionality are separate from an output’s null representation. Byte slices describe base64. Arbitrary interfaces, recursive or excessive-depth graphs and opaque custom JSON/text codecs require a schema override. `json.RawMessage` is an intentionally unconstrained field. ## Override a custom codec Use `arc.RegisterSchema[T](builder, document)` with a JSON schema object or boolean. The document is copied and structural keywords are validated before use. No custom codec is executed to guess its schema. Duplicate exact-type overrides fail. This is bounded Go schema support, not full .NET JsonSchemaExporter equivalence, polymorphism, validation-rule export, OpenAPI or TypeScript proxy generation. # MongoDB snapshots and observations > Serve authorized MongoDB pages and rerender them on bounded database invalidations. When your read models already live in MongoDB, you can return a typed selection from an Arc query instead of loading every row and paging in memory. The optional `github.com/cratis/arc.go/integrations/mongodb` module counts authorized rows and pushes sorting, skip and limit to MongoDB before Arc publishes the result. **Source preview, Partial snapshot provider:** there is no tagged module release. Use Go 1.26 or later and the independently fetchable dependency pins in the module. The live contracts target MongoDB **8.0.15**, a single-member replica set and the official Go driver v2.9.1. Count and find are separate reads, not one atomic snapshot. Database invalidation sources are **Partial**, with manual registration and terminal recovery rather than automatic resume. Real Chronicle sink layout and release compatibility are not established. ## Run the manual HTTP example Prerequisites: a checkout of Arc.Go, Go, `mongosh`, and your own loopback MongoDB replica set. The example creates no database server. It listens only on `127.0.0.1:8080`; its environment-configured bearer secret is a local demonstration, not a production identity-platform adapter. Do not point it at shared data. Set the connection for your own server. For an externally published single-member replica set, use its actual loopback port and direct connection: ```bash export ARC_MONGODB_URI='mongodb://127.0.0.1:27017/?directConnection=true' export ARC_EXAMPLE_TENANT=Demo export ARC_EXAMPLE_TOKEN='replace-with-a-random-local-secret-at-least-32-bytes' ``` Create application-owned rows and an index in the example’s `Library+Demo` database. These commands intentionally write to your selected local database: ```bash mongosh "$ARC_MONGODB_URI" --quiet --eval ' const authors = db.getSiblingDB("Library+Demo").getCollection("Authors"); authors.insertMany([ {_id: 1, Name: "Ada", Active: true, OwnerID: "reader"}, {_id: 2, Name: "Grace", Active: true, OwnerID: "reader"}, {_id: 3, Name: "Hidden", Active: true, OwnerID: "another-reader"} ]); authors.createIndex({OwnerID: 1, Active: 1, Name: 1, _id: 1}); ' cd integrations/mongodb GOWORK=off GOTOOLCHAIN=local go run ./examples/httpserver ``` The [complete host](https://github.com/Cratis/Arc.Go/blob/develop/integrations/mongodb/examples/httpserver/main.go) configures the driver, verifies the local secret, selects the fixed tenant, checks membership, registers the provider and drains Arc before disconnecting the borrowed client. The model and namespace query below are a maintained declaration excerpt from the compiled example: ```go type Author struct { ID int32 `json:"id" bson:"_id"` Name string `json:"name" bson:"Name"` Active bool `json:"active" bson:"Active"` } // AllActive is a namespace query returning a trusted MongoDB predicate. func (Author) AllActive(context.Context, queries.NoArguments) (mongodb.Find[Author], error) { return mongodb.Find[Author]{Filter: bson.D{{Key: "Active", Value: true}}}, nil } ``` Registration uses `NewCollection[Author]`, a borrowed operation resource holder, `RegisterRenderer[Find[Author], []Author]` and `RowFilter` on the verified subject’s `OwnerID`. `SnapshotHTTPExample` supplies GET `/authors`; no discovery or generated MongoDB adapter is implied. In another terminal with the same token, fetch the second authorized row: ```bash curl -sS 'http://127.0.0.1:8080/authors?page=1&pageSize=1&sortby=Name&sortDirection=asc' \ -H "Authorization: Bearer $ARC_EXAMPLE_TOKEN" ``` The 200 query envelope contains `data: [{"id":2,"name":"Grace","active":true}]` and `paging: {"page":1,"size":1,"totalItems":2,"totalPages":2}`. The hidden row contributes neither data nor count. Without credentials this route returns 403 and performs no MongoDB operations; invalid credentials are rejected by ingress. Stop the host with Ctrl-C. To repeat seeding, remove only the three IDs you created in your own local database, or choose a fresh tenant and matching database; do not drop a shared database. ## Configure your own collection Give each persisted field an explicit JSON name. Use `bson` overrides for existing storage names; absent BSON tags use the exact JSON name. Bindings require an ordinary nonnullable scalar `_id`, an explicit database, collection and ownership. See the [BSON storage profile](https://github.com/Cratis/Arc.Go/blob/develop/integrations/mongodb/README.md#storage-profile) for supported types, null semantics, UUID byte order and temporal precision. `RowFilter` is mandatory. A missing callback or a nil returned predicate fails closed; explicit `bson.D{}` intentionally authorizes all rows. Arc membership and endpoint authorization precede provider operations. Filters are trusted application BSON, never request-supplied operators. Declared top-level JSON sort fields map to storage names, with `_id:1` appended for deterministic ties. Defaults retain at most 1000 rows and 16 MiB raw BSON within a ten-second operation budget. Oversized pages, retained-size overflow and count-to-page overflow fail, not truncate. Acquired cursors close with a detached five-second cleanup budget. Count/find/getMore, decoding, release and scope-cleanup failures retract all data and paging metadata. The required paging envelope remains present with zeros. A canceled HTTP request cannot encode a response body; it is not partial success. Configure least-privilege read credentials and verified TLS for production. Applications own topology, indexes, read-retry settings and client lifetime. Primary/majority and simple collation are the configured profile; the driver may rewrite Primary to primaryPreferred for direct/Single connections. Use replica-set discovery for topology-aware primary selection. Bounds do not make unindexed authorized counts cheap. ## Observe an authorized collection When a page should change with its collection, return invalidation instructions instead of fetching rows inside a change-stream callback. Arc rechecks membership and policies before each candidate, then uses the same renderer for fresh row authorization, count, window, release and interception. The compiled [ObservationExample](https://github.com/Cratis/Arc.Go/blob/develop/integrations/mongodb/examples/observation/observation.go) constructs an application-lifetime watcher and manually registers `Source[Find[Author]] -> []Author` against the existing renderer. It accepts your borrowed client and membership authority; verified identity and tenant must reach Arc before Open. This source declaration is an excerpt from that example, using its `Author` and the `mongodb`, `observable` and `bson` imports: ```go // ActiveAuthors returns trusted invalidation instructions, not rendered rows. // Register this source against the renderer for the same collection binding. func ActiveAuthors(watcher *mongodb.Watcher, authors *mongodb.Collection[Author]) (observable.Source[mongodb.Find[Author]], error) { return mongodb.Observe(watcher, authors, mongodb.Find[Author]{Filter: bson.D{{Key: "Active", Value: true}}}) } ``` Pair the source and renderer with the **same collection binding**. Use per-query `WithRenderer` when one model has multiple bindings; model type does not identify a server or database. Opaque provider emissions require manual registration, not a generated proxy. The example has no HTTP host, credential verification, seed data or indexes. Check its registration from the module directory: ```bash GOWORK=off GOTOOLCHAIN=local go test -run ExampleObservationExample -v ./examples/observation ``` It prints `true []observation.Author`. Actual streaming/waiting requires your supported replica set and database change-stream permissions. Non-wait snapshots remain pending because this is `Source`, not `CurrentSource`. Waiting or streaming establishes the database cursor **before** the initial renderer baseline. Inserts, updates, replacements and deletes invalidate the complete selection, including filter exits and off-page changes. Mutations during rendering or blocked delivery remain queued. Quiescent comparison proves convergence, not atomic count/find or one delivery per historical mutation; equivalent duplicate results are allowed. Share one watcher per borrowed client. Models and collections sharing a resolved database share its cursor; distinct clients and tenant databases do not. Default limits are 32 databases, 1024 subscribers, 64 per logical query and 16 queued markers. Overflow terminates only the slow subscriber. Each candidate uses a fresh RowFilter; membership or policy revocation before that candidate prevents renderer I/O and produces terminal denial. There is no idle-policy polling. Cursor loss and drop/rename terminate the database generation with locally inspectable `ErrResnapshotRequired`, not an in-place resume. Clients receive Arc’s safe terminal error. Close the old observation and explicitly Open a new one after reader/cursor cleanup joins; an unjoined generation rejects admission with `ErrJoinPending`. Fresh Open establishes a new cursor and full first Delta result, then subsequent changes. A generation hint alone does not reset delivery state. There is no live connection migration, durable checkpoint, replay, gap-free recovery or browser automatic-resubscription guarantee. Streams retain Open’s cancellation channel and copied deadline, not its query metadata or context values. Cancellation first observed at/after that deadline reports `context.DeadlineExceeded`; before it, `context.Canceled`. The first classification stays fixed, so an earlier cancellation observed late cannot preserve its historical error or custom cause. Next’s supplied context still propagates its actual error. Stream Close releases this state only after joining active Next work. Drain `CloseObservations`, then join `Watcher.Close`, then disconnect your borrowed client. Continue a canceled join wait with a fresh context. A failed cursor disposal remains an error and blocks generation replacement; repeating Close does not repeat that failed driver effect. See the [observation ownership reference](https://github.com/Cratis/Arc.Go/blob/develop/integrations/mongodb/README.md#database-invalidation-sources) for all bounds and C# differences. ## Chronicle-owned boundaries `ChronicleOwned` requires `Release`. The callback receives complete copied raw BSON, including opaque ciphertext and lineage, before protected-field decoding or Arc interception. It owns actual mapping and release. Return the same row count/order/identity; errors, partial output, reordered identities and duplicate decoded identities fail with no publication. Distinct BSON IDs such as integer `1` and string `"1"` can coerce to one Go ID: this ambiguity fails closed rather than guessing correspondence. Only approved cleartext sink fields may be filtered or sorted. The recording callback tests use synthetic ciphertext and lineage. They prove boundary enforcement, **not** a real Chronicle ciphertext layout, SDK release call or compliance guarantee. Do not configure an ordinary decoder as a fake release. ## Provider contracts and limits From the repository root, run the required live lane with Docker available: ```bash python3 integrations/mongodb/scripts/replica-set-tests.py \ --state-file "$PWD/.ai-work/keep/mongodb-provider-local-unique.json" ``` Use a fresh state-file path for each task. The harness owns one fresh-storage container, pins `mongo:8.0.15` by digest, verifies its image ID, asks Docker for a loopback port, initiates the replica set and supplies the external direct URI. It budgets 90 seconds for startup, 180 for tests and 15 for diagnostics/cleanup. Failure logs precede removal of only the exact container with its ownership token, including anonymous volumes. Signal cleanup and CI’s `always()` fallback use that same token/ID, never a shared container, database drop, port-owner kill or prune. Missing Docker, URI or failpoint support fails the required lane rather than passing through a skip. Ordinary module tests never require a database. `provider_*_integration_test.go` exercises the actual registered renderer and Arc HTTP host: two tenants, forbidden rows, multi-batch cursors, serialized count/find predicate/coordinate/collation equality, server windows, deterministic ties, bounds, empty/out-of-range pages, no double paging, UUID/concept/null/temporal storage, decode failure, synthetic release, count/find/getMore failpoints, cancellation/cursor cleanup and borrowed-client usability. The separate `observation*_integration_test.go` cases use real ordinary `Aggregate($changeStream)` cursors through Arc Open/Run, not `Database.Watch` or a callback refetch. They compare every full candidate with the equivalent authorized unary query, including initial-render/delivery barriers, filter entry/exit, off-page mutations, reorder/refill, totals, independent principals/tenants/models, shared versus distinct clients, fresh row filters and synthetic raw release before interception. Driver command monitoring checks the single database watch, metadata-only projection, bounded batches and absence of UpdateLookup/resume. Revocation denies before render I/O; overflow remains subscriber-local. Live startup cancellation, canceled cleanup joining, cleanup-budget failure, CursorNotFound and injected ChangeStreamHistoryLost, explicit new-Open first-full Delta recovery, drop and rename are covered. Natural small-oplog history expiry is **unverified**: a failpoint response is not evidence of actual retention expiry. Concurrent Open/Close/Next and broader limit boundaries also have deterministic unit coverage. Huge count conversion/overflow cases use bounded no-database driver fakes; the live lane does not seed billions of rows. The manual example also crosses a real loopback HTTP listener. Linux Go 1.27 is the required provider CI profile; ordinary no-database checks keep the existing toolchain/OS matrix. Follow the [parity map](/arc/go/parity/) for remaining gaps. Sharded or multi-member deployments, failover and real Chronicle sink release remain outside this evidence. Collection joins, single/null observations, CurrentSource/replay, automatic recovery and opaque-provider generation are deferred. # Run observable MongoDB queries on several instances > Route hub traffic with owner affinity, size limits per instance, and shut instances down without leaking change-stream cursors. Once you run more than one copy of an application behind a load balancer, an observable query is no longer a single process concern. Each instance must notice database changes on its own, a client’s control requests must reach the instance that holds its stream, and losing an instance must not leave cursors or resources behind. This page describes how Arc.Go behaves there and what you must provide. Status: **Partial**. The evidence is two independent Arc applications behind a real loopback HTTP router, sharing one MongoDB 8.0.15 replica set. It is not a multi-process, browser or production load-balancer certification. ## Each instance is independent Every application instance owns its own MongoDB client, `mongodb.Watcher`, query pipeline and hub registry. Nothing is shared in memory, and Arc.Go has no broker or distributed subscription registry. Only the database and collection coordinates are common. * Each instance opens its own change-stream cursor for each tenant database it observes. Two instances watching two tenant databases hold four cursors. * A database change reaches every instance through its own cursor. Each subscriber then rerenders with its own authorized row filter, so another principal’s or tenant’s rows are never delivered as a side effect. * Multiple principals on one instance share that instance’s database cursor, not rows. You do not need to coordinate instances for correctness of delivered data. You do need routing affinity for controls. ## Route hub controls with owner affinity A Server-Sent Events hub connection lives on the instance that accepted its `GET /.cratis/queries/sse`. The subscribe and unsubscribe controls are separate `POST /.cratis/queries/sse/subscribe` and `/unsubscribe` requests that name the connection ID. They only work on that same instance. | Control request | Result | | --- | --- | | Correct owner, same instance | 200; the result arrives on the stream | | Correct owner, **other** instance | 404; no resources are opened | | Wrong subject or tenant, an anonymous caller controlling an authenticated owner’s connection, or an anonymous caller whose ownership evidence (connection cookie or `AnonymousOwner` result) does not match, on the right instance | 404 | | Any caller after the owning instance is gone | 404 | Configure your load balancer so that the hub GET and its controls reach the same instance, for example with load-balancer session affinity for the client. The ownership check still applies on top of routing: affinity is required, but it does not grant access. See [own SSE controls securely](/arc/go/backend/go/queries/observable-query-hub/#own-sse-controls-securely) for the ownership rules. Direct observable SSE requests carry the query in the GET itself and need no affinity beyond the lifetime of that one response. The WebSocket hub sends controls over the same socket, so they cannot reach another instance; the two-instance tests exercise the SSE hub and direct SSE, not the WebSocket hub. ## Size limits per instance All limits apply to one application instance, not to the deployment: * `ObservableOptions` limits such as `MaxObservations`, `MaxConnections`, `MaxConnectionsPerOwner` and `MaxSubscriptions` (see the [hub limits](/arc/go/backend/go/queries/observable-query-hub/#limits-and-shutdown)). * `WatcherOptions` limits such as `MaxDatabases`, `MaxSubscribers`, `MaxSubscribersPerQuery` and `Buffer`. A caller allowed eight hub connections per owner can therefore hold eight on each instance. Size database change-stream capacity for instances times observed tenant databases. ## Lose an instance When an instance’s connections drop, its streams end. That instance’s watcher cursors and invocation resources are joined when you shut it down. Controls naming its connections return 404 on the surviving instances. Survivors keep delivering to their own subscribers. Recovery is explicit and has no continuation token. The client opens a new hub connection, receives a new connection ID and subscribes again. The first result is a full baseline even when the query ID and revision are reused. An unsubscribe and resubscribe on the same connection also resets that subscription’s delta baseline. There is no transparent resume, connection migration, replay or gap-free delivery across the loss. ## Shut an instance down Shut down in ownership order: 1. `Application.Shutdown` cancels and joins observations, hub subscriptions and their invocation resources. 2. `Watcher.Close` joins the database readers and closes their change-stream cursors. 3. Disconnect your borrowed MongoDB client. The two-instance tests check that every opened invocation resource is closed exactly once, that each instance issues one `killCursors` per observed database, and that the borrowed client is still usable after the watcher closes. See [MongoDB observation](/arc/go/backend/go/mongodb/#observe-an-authorized-collection) for the watcher lifecycle on one instance. ## Evidence and limits `integrations/mongodb/two_instance_integration_test.go` runs in the tagged Linux MongoDB lane: * `TestLiveTwoInstanceProviderBroadcastAndIsolation` covers broadcast to both instances, tenant and principal isolation, and per-instance cursor counts. * `TestLiveTwoInstanceHubAffinityLossAndExplicitBaselineReset` covers wrong-instance and wrong-owner control rejection without opening resources, transport loss, explicit reconnect, full baselines and the unsubscribe/resubscribe reset. Not covered: separate OS processes, process crashes, a real load balancer or browser, MongoDB failover or sharding, and the WebSocket hub across instances. # Queries in Go > Keep query behavior beside its read model and execute typed snapshots without a container. Keep selection logic beside the state it returns. Arc.Go queries belong to an ordinary read-model struct, with unnamed value-receiver methods standing in for C# static methods. You can also use explicitly owned top-level functions. Start with a snapshot, or register an [observable source](/arc/go/backend/go/queries/observable-queries/) when you need live results. HTTP hosting supports both; handwritten registration remains the observable authoring path while generated observable adapters await their separate tools lane. Source directives do not register queries at runtime. No database or dependency-injection container is required. ## Return a first snapshot Save this complete program in a module that depends on Arc.Go, then run `go run .`. It prints `Ada`. ```go package main import ( "context" "fmt" "github.com/cratis/arc.go/metadata" "github.com/cratis/arc.go/queries" ) //arc:readmodel type Author struct { Name string `json:"name"` } func (Author) All(context.Context, queries.NoArguments) ([]Author, error) { return []Author{{Name: "Ada"}}, nil } func run() error { var registry queries.Registry err := queries.Register[Author](®istry, "All", queries.Function(func(ctx context.Context, args queries.NoArguments) ([]Author, error) { return Author{}.All(ctx, args) }), queries.WithAuthorization[queries.NoArguments](metadata.Authorization{AllowAnonymous: true}), ) if err != nil { return err } pipeline, err := registry.Build(queries.PipelineOptions{}) if err != nil { return err } result, err := queries.Perform[[]Author](context.Background(), pipeline, "Author.All", queries.Request{}) if err != nil { return err } if !result.IsSuccess() { return fmt.Errorf("query failed with status %d", result.StatusCode()) } authors, _ := result.Data() fmt.Println(authors[0].Name) return nil } func main() { if err := run(); err != nil { panic(err) } } ``` `Register` records the owning model, query identity and typed callback. `Build` validates the catalog without invoking the callback. `Perform` opens an operation scope, checks security and validation, and returns a ready result. A successful nil model is ready-null, not a pending query. ## Choose the next step * [Model-bound authoring](/arc/go/backend/go/queries/model-bound/): namespace methods, directed functions and shared read models. * [Query arguments](/arc/go/backend/go/queries/model-bound/query-arguments/): explicit requiredness, defaults and presence. * [Paging](/arc/go/backend/go/queries/model-bound/paging/): provider rendering versus already-windowed pages. * [Query pipeline](/arc/go/backend/go/queries/query-pipeline/): lifecycle, staging and local errors. * [Query results](/arc/go/backend/go/queries/results/): the unchanged wire envelope and status precedence. * [Observable queries](/arc/go/backend/go/queries/observable-queries/): owned sources, current values and subscriptions. * [Observable HTTP](/arc/go/backend/go/queries/observable-http/): current/pending/wait and direct transports. * [Observable hubs](/arc/go/backend/go/queries/observable-query-hub/): revisions, ownership and transfer modes. # Collection change streams > Choose full, delta or legacy transfers and understand identity, privacy and delivered baselines. Sending a large list after every small change wastes bandwidth. Hub collection transfers can send only the added, replaced and removed items after the first snapshot. Direct SSE/WebSocket and Go `Subscribe` still send complete results. Actual JavaScript/React reconstruction has not been executed against this branch; server wire evidence is not a browser compatibility claim. ## Choose the transfer mode | Hub mode | First delivered result | Later delivered results | | --- | --- | --- | | `full` | Full data, no change set | Full data, no change set | | `delta` | Full data, no change set | Change set, data omitted | | `legacy` | Full data plus added-all change set | Full data plus change set | Missing/unknown textual modes select legacy. Single models and collections without an eligible identity always use full snapshots without change sets. Empty declared collections retain their item type; emptiness does not disable identity metadata. A change set has three required arrays. Removed entries are complete previously delivered items, not just IDs: ```json {"added":[{"id":"c","title":"new"}],"replaced":[{"id":"a","title":"changed"}],"removed":[{"id":"b","title":"gone"}]} ``` An unchanged collection sends empty arrays. Reordering alone is not a positional change. Clients reconstruct by identity, not array index. The wire does not promise that delta reconstruction will reproduce a provider-only reorder. ## Keep server and client identity compatible Registration compiles case-insensitive exported Go `ID`/`Id` member extraction. It does not reuse command `arc:"key"` metadata. A custom `WithCollectionIdentity[A,T](extract)` uses a typed `func(T) (any, error)`; unsupported identity encoding fails rather than publishing partial differences. Emit a client-compatible JSON identity, conventionally `id`. A custom server extractor does not teach the stock JavaScript client a differently named property. Null identities are skipped on the identity path. Duplicate IDs use their last value with deterministic first-encounter ordering. `queries.ComputeChanges(previous, current)` also supports identity-less JSON-set comparison: additions/removals, never replacements. That standalone fallback is **not** the hub’s identity-less transfer policy; hubs send full snapshots instead. ## Commit only delivered state The baseline is the last successfully delivered, intercepted snapshot, not the latest source value. The pipeline prepares immutable serialized candidates, reserves retained bytes, and commits only after the delivery callback succeeds. Hub callbacks wait for local writer acknowledgement. Queue admission, suppression, stale ownership, cancellation, encoding errors and failed writes never advance it. Cancellation after candidate reservation releases that candidate too. Local successful write/flush is not browser acknowledgement, durable receipt or exactly-once delivery. On replacement or reconnect, the first delta result is full again. There is no replay/resume token. Removed items contain only the previously delivered masked fields; neither provider mutation nor callback mutation can rewrite that baseline. Candidate and previous baseline coexist until acknowledgement and both count against retained-byte budgets. `ObservationOptions.MaxBaselineBytes` defaults to 16 MiB; hubs use `HTTP.MaxResponseBytes`. `ReserveBaseline` is an optional host reservation callback returning an idempotent release function. Connection and application budgets also include queued/in-flight frames. Oversize or exhaustion fails explicitly; there is no silent eviction/coalescing. ## Supply known-change hints conservatively An observable may emit a **value** `queries.ObservedCollection[T]` with: | Field | Meaning | | --- | --- | | `Items` | Complete current `[]T`, rendered as query data | | `Changes` | Optional `CollectionChange` identities and kinds | | `Version` / `PreviousVersion` | Source-local continuity hints | | `Generation` | Source generation for continuity | `CollectionAdded`, `CollectionReplaced` and `CollectionRemoved` describe the hint kind. Versions/generation are server metadata, never wire subscription revisions. Zero versions or an empty generation disable continuity hints. Pointer wrapper declarations are rejected; declare `observable.Source[queries.ObservedCollection[T]]`. Hints are compared with the complete intercepted diff. Missing changes, broken predecessor/generation continuity, suppressed versions or transformations that invalidate hints fall back to ordinary diff computation. Unknown hint identities cannot hide an independently detected change. This is conservative correctness, not a claimed faster algorithm. Select authorized rows before paging/counting and apply [interception and guards](/arc/go/backend/go/queries/observable-emission-guards/) before transfer. Chronicle and MongoDB watch adapters are not implemented; do not synthesize authoritative read-model watches from command append notifications. # Model-bound queries > Share ordinary read-model structs between Arc queries and Chronicle projections without coupling registration. Put query methods beside their read model so you can find state and selection logic together. The model remains an ordinary named struct: no base type, constructor, service-provider field or hidden framework state. Use [unnamed namespace methods](/arc/go/backend/go/queries/model-bound/static-methods/) as the static-equivalent convention. Directed top-level functions are equally supported. Register the model only when you need shared identity, path or authorization overrides; otherwise `queries.Register[M]` uses its type name and the registry namespace. ## Shared Arc and Chronicle rules One struct can serve both frameworks. These are the agreed shared type rules, not a claim that Arc executes Chronicle projections: 1. **Disjoint metadata.** Arc owns `arc:"…"` and `//arc:`; Chronicle owns `chronicle:"…"` and `//chronicle:`. Neither interprets the other’s metadata; both tolerate other tag keys. Arc validates unknown options within its own tags. 2. **Explicit wire names.** Use explicit `json` tags. Queries, proxies, projection paths, read-model schemas and indexes must agree on the exact serialized name. Do not rely on different framework default naming policies, especially initialisms. 3. **Exported state only.** Only exported serializable fields participate. `json:"-"` fields are invisible to both and cannot carry Chronicle mappings. 4. **Pointer nullability.** `*T` means nullable/absent. `omitempty` and `omitzero` affect serialization only; they do not change projection semantics. 5. **Concepts.** Domain values can implement Fundamentals.Go `concepts.Concept[T]`. `concepts.Underlying` recognizes the scalar representation and `CheckJSON` checks codec shape. Plain named primitives need no extra marker. Query text binding uses `UnmarshalText`, not private concept state. 6. **Separate keys.** Chronicle’s read-model key is `chronicle:"key"`, with `ID`/`Id` fallback. Arc identity is `arc:"identity"`; argument declarations remain separate. These declarations may reference the same field but neither implies a command key or a Chronicle event-source identity. 7. **Separate registration.** Register the same type independently with each framework: Arc registrations/adapters and Chronicle read models/projections. There is no shared global registry and no `init` registration. This shared declaration excerpt uses primitives only: ```go type Item struct { ID int `json:"id" arc:"identity" chronicle:"key"` Name string `json:"name"` Note *string `json:"note,omitempty"` } ``` Namespace methods are ignored by serialization and projection field inspection. Chronicle owns FromEvent/SetFrom-equivalent declarations. Arc registration implies neither projection ownership nor command-side model injection; projection ownership must not require Arc registration or invoke query methods. The [Chronicle integration](/arc/go/backend/go/chronicle/#one-model-two-frameworks) includes a shared model registered with both frameworks and a kernel test that projects events and serves the result through Arc. Query registration alone demonstrates no persistence. ## Pin public identity deliberately `queries.NewRegistry(queries.RegistryOptions{Namespace: "Shop.Inventory"})` sets a logical namespace, never one derived from an import path or folder. `RegisterReadModel[M]` accepts `WithModelIdentity`, `WithModelPath` and `WithModelAuthorization`. Explicit query descriptors remain available for migrations. For nongenerated declarations, a single blank metadata field can use `json:"-" arc:"readmodel,name=Item,namespace=Shop"`; authorization uses the shared metadata grammar. Type renaming changes the default public identity: pin it before moving or renaming a model consumed by clients. Continue with [namespace methods and functions](/arc/go/backend/go/queries/model-bound/static-methods/) or [method-over-model authorization](/arc/go/backend/go/queries/model-bound/authorization/). # Query authorization > Replace model authorization at the query level and suppress ordinary factories on denial. Protect the model’s query family once, then override a particular query when it needs a different audience. Method authorization **replaces**, not combines with, model authorization. Requirements within a declaration retain AND semantics; roles within one requirement retain OR semantics. ## Supply declarations Use `WithModelAuthorization` on `RegisterReadModel[M]`, and `WithAuthorization[A]` on `Register[M]`. `WithDescriptor[A]` can pin the complete method declaration. `AllowAnonymous` cannot combine with restrictions at that same declaration level. Authentication schemes remain explicitly unsupported. A supplied `authorization.Evaluator` must cover the complete catalog with exactly matching declarations. Matching names alone is insufficient. Nil authorization builds an evaluator with an empty policy registry: explicit authentication/roles still run, and unknown named policies fail Build. With no declaration or configured fallback, the existing authorization compiler treats an operation as public. See [authorization declarations](/arc/go/backend/go/authorization/) for shared policy and fallback semantics. Source directives are generator inputs, not runtime comments. ## Preserve factory staging The pipeline enforces membership even on public queries. Declaration and custom authorization filters run before ordinary filter and validator factories. Performer factories/dependency resolution wait until ordinary validation succeeds. Authorization is reevaluated immediately before invocation. Scope and prepared security checks retain principal and tenant values **and their presence bits**. A resource scope is not an authorization token. Adding an empty principal or tenant to a context that originally lacked it is a security change, not a harmless default. Denials are ready failures without data. A policy infrastructure error is denied with safe exception diagnostics; it is not permission to continue. Query envelopes do not expose a denial-reason field. # Query dependencies > Supply repositories through closures or lazy operation resources after authorization and validation. Business query methods can accept ordinary typed collaborators. Adapt those parameters at composition time; caller-input fields never become dependencies merely because a DI catalog contains their types. ## Capture a dependency `queries.Function` accepts a closure. With your repository already constructed, call it from that closure, as in the compile-checked `ExampleFunction_closureDependency` in `queries/example_test.go`. Shared captured collaborators must support concurrent calls. This is the smallest container-free path. It declares no DI keys and requires no resource opener. It is suitable for stateless repositories and application-owned objects whose lifetimes already cover pipeline calls. ## Obtain operation resources lazily Use `queries.Scoped` to adapt a concrete application holder implementing `execution.Resources`. Use `queries.Invoke` when an adapter needs an expiring `Invocation.Scope()`. `execution.ResourcesAs[T]` checks a holder’s type; `execution.Resolve[T]` obtains a service through its guarded resolver. The performer-stage adapter runs only after declaration authorization, membership, filters and argument validation. Invalid arguments and denied callers must not construct performer dependencies. Factories return borrowed instances; the resource holder owns their disposal. Do not retain an invocation, scope view or resolver. ## Use an optional DI provider `PipelineOptions.ScopeFactory` adapts Fundamentals.Go’s factory. A factory’s `Catalog` and `ScopeOwner` are optional capabilities in the published contract. Supply `DependencyCatalog` separately if the factory does not expose it. `WithDependencies[A](di.KeyFor[Repository]())` declares static requirements; Build checks keys without resolving services. Missing services at execution remain infrastructure errors, not ordinary validation rejection. `OpenResources` and `ScopeFactory` are mutually exclusive. A borrowed DI scope used with a configured factory requires `ScopeOwner` and must belong to it; otherwise execution fails with `execution.ErrScopeOwner`. Plain custom holders need only Arc’s security/lifetime guard. Continue with [pipeline ownership](/arc/go/backend/go/queries/query-pipeline/) for cleanup and cancellation. # Paging and sorting > Count authorized rows before stable ordering and paging without changing ordinary-list behavior. A paging request does not automatically page every list. Choose a provider renderer when you want the source to count, order and window, or return `Page[T]` when your query already did that work. ## Request controls | Control | GET | QUERY | | --- | --- | --- | | Paging activation | Any parsed int32 `pageSize`, including zero/negative | Positive integer int32 `pageSize` only | | Page | Invalid/missing defaults to zero | Integer token required when present | | Sorting | Nonempty `sortby` and `sortDirection` together | Nonempty `sorting.field`; omitted/null direction is ascending | | Direction grammar | `asc`, `ascending`, `desc`, `descending`, case-insensitive | Same; explicit empty fails | The pipeline validates active page ≥ 0 and size > 0, using C# concept messages and `Page`/`Size` members. This intentionally enforces rules that the older C# request path did not always execute. `Paging.Skip()` uses int64 multiplication and clamps to `[0, MaxInt32]`. ## Render at the source A `Renderer[Q,R]` receives `QueryContext.Parameters()`. Apply row authorization, count the authorized selection, establish an order, then window. Return `RendererResult[R]{Data: data, TotalItems: total}`. Filtering after the window or merely masking rows can leak totals and produce incorrect pages. `Page[T]` supplies already-windowed items and the full authorized total. The pipeline never applies a second window. Unpaged results preserve zero `PagingInfo`. For active rendered/page output, metadata includes page, size, total items and computed total pages. ## Opt into in-memory rendering `NewSliceRenderer[T]` accepts `SortColumn[T]` entries with exact wire-field names and comparison callbacks. It copies slice membership, counts before paging, stable-sorts and windows. It does not deep-clone items, or turn an expensive full-data fetch into provider-side paging. Only registered columns can sort. An unknown active field yields safe validation, not reflected property access. Wire names stay unchanged; the renderer also accepts the corresponding PascalCase transport spelling. Comparators must be concurrent-safe and must not mutate models. Plain slices without a renderer are neither counted nor sliced, even when controls request paging. The `ExampleWithParameters_page` example in `queries/example_test.go` executes an already-windowed page. Continue with [interception](/arc/go/backend/go/queries/read-model-interception/) to transform output after rendering. # Query arguments > Bind flat typed arguments with explicit requiredness, defaults and raw presence. Declare a named argument struct separately from your read model. Arc compiles its exported fields when you register the query, so ordinary callers need no setters. Use `NoArguments` for queries without caller input. ## Declare input rules This declaration excerpt uses the `serialization` package for explicit presence: ```go type SearchArguments struct { Category string `json:"category" query:"required"` Limit int `json:"limit" query:"default=10"` Note serialization.Optional[string] `json:"note" query:"preservePresence"` } ``` Requiredness is explicit. Supplied zero and false are not missing. Required and default cannot combine. Options use comma separation; backslash escapes comma, equals and backslash in default text. Unknown, duplicated or malformed options fail. | Input | Binding behavior | | --- | --- | | Missing | Typed default if supplied, otherwise zero/missing; required yields `rule` | | GET empty | Omitted effectively unless `preservePresence` | | QUERY null or empty string | Omitted effectively unless `preservePresence` | | Supplied malformed scalar | `malformedRequest`; performer never runs | | Direct `NewArguments` empty/null | Missing/default rules for types unable to represent empty strings, unless `preservePresence`; strings retain empty values | | `RequestFor[A]` | Exact typed input; no conversion, omission or transport defaults | `Request.Arguments()` and `QueryContext.Arguments()` retain raw missing/null/empty presence separately from effective values. Binding constructs a fresh argument model and publishes it only after every declared conversion succeeds. ## Supported conversions * Primitive and named scalars, numeric enums, standard text codecs and valid concepts. * Pointers and `serialization.Optional[T]` of supported inputs. * Flat scalar collections and arrays; fixed arrays require the exact element count. * Explicit `BindArgument` setters with custom `DecodeText`/`DecodeJSON`. GET repeated collection values are split on commas and trimmed. QUERY arrays preserve element boundaries; nested objects/arrays are invalid scalar elements. JSON-node collections require explicit `json.RawMessage` bindings and custom JSON conversion. No automatic nested DTO binding or enum-name inference is provided. Wire names use `json` tags, otherwise Arc’s acronym-friendly naming. Names match case-insensitively. Duplicate names, reserved control names, unsupported field shapes and invalid defaults fail registration. Unknown supplied arguments remain raw and never invoke setters or choose dependencies. Explicit bindings replace matching compiled fields; additional bindings retain their declared order. For the envelope and control grammar, see [HTTP QUERY](/arc/go/backend/go/queries/using-the-http-query-method/). For successfully bound model rules, see [query validation](/arc/go/backend/go/queries/validation/). # Query return types > Choose owning-model snapshot shapes, already-windowed pages or exact provider renderers. A query belongs to one named, nonpointer read-model struct `M`. Its client data must be that model or a supported collection of it; unrelated models do not become eligible because they happen to have the same fields. ## Snapshot matrix | Performer output | Client data | Paging | | --- | --- | --- | | `M`, `*M` | Same declared shape | None | | `[]M`, `[]*M`, arrays of either | Same collection shape | None by default | | `Page[M]`, `Page[*M]` | `[]M`, `[]*M` | Already windowed; total supplied by performer | | Provider output `Q` with exact `Renderer[Q,R]` | Supported owning-model `R` | Renderer-owned | | Bare channel or iterator | Rejected | Adapt to an owned observable source | A successful nil single model is ready-null and skips interception. `Page` is unwrapped once and is never rendered or paged again. Plain slices stay unpaged unless you explicitly register a renderer. ## Observable shapes Use `RegisterObservable[M,A,O]` for `observable.Source[O]`; its emitted/rendered model must satisfy the same owning-model rules. `State`, `Subject` and the owned producer/iterator/channel-factory adapters implement this source contract. `WithEnumerable[A]()` selects streaming-only behavior. Ordinary `Register` never treats arbitrary channels as lifecycle-bearing queries. See [observable queries](/arc/go/backend/go/queries/observable-queries/) and [collection transfers](/arc/go/backend/go/queries/change-stream/). ## Render a provider query Register a reusable renderer with `RegisterRenderer[Q,R]`, or select a per-query override with `WithRenderer[A,Q,R]`. Registration keys are exact declared types, not the first assignable runtime type. Build validates rendered ownership without calling a factory. An unrelated raw output is rejected at Build unless an exact renderer supplies an eligible result shape. `Registration.ReturnType()` describes the raw performer output; `DataType()` describes the rendered/unwrapped data. For `RegisterObservable`, `ReturnType()` is the declared `observable.Source[O]`, `EmissionType()` is `O`, and `DataType()` describes its rendered data. Value `ObservedCollection[T]` emissions unwrap to `[]T`; pointer wrappers are rejected. The generic `queries.Perform[R]` checks known compatibility before running application code. Provider dependencies, counting and storage I/O belong inside rendering, not Build. See [paging and sorting](/arc/go/backend/go/queries/model-bound/paging/) and the executed provider example in `queries/example_test.go`. # Namespace methods and query functions > Translate C# static read-model queries into direct, typed Go calls. Go has no static methods. Use an unnamed value receiver as a namespace: it places query behavior beside the model without pretending that the model is a handler instance or a dependency-injection service. ## Declare a namespace method The declaration below is the model portion of the [first snapshot example](/arc/go/backend/go/queries/). ```go //arc:readmodel type Author struct { Name string `json:"name"` } func (Author) All(context.Context, queries.NoArguments) ([]Author, error) { return []Author{{Name: "Ada"}}, nil } ``` The receiver has no name and the method does not depend on instance state. Manual composition adapts the direct call `Author{}.All(ctx, args)` with `queries.Function`. No dummy model instance is needed at an application composition site. Future generated discovery uses only declared, nongeneric methods with unnamed or blank value receivers and supported owning-model returns. Promoted methods do not become queries. Explicit pointer/stateful query declarations must be diagnosed, not invoked reflectively. Generation is separate tooling work; these runtime APIs do not discover methods or read source comments. ## Declare an owned function With `Author` defined above, this alternative declaration is equally supported: ```go //arc:query model=Author name=Recent func RecentAuthors(context.Context, queries.NoArguments) ([]Author, error) { return []Author{{Name: "Grace"}}, nil } ``` Manual composition uses `queries.Register[Author](registry, "Recent", queries.Function(RecentAuthors))`, with any desired metadata options. The directive records intended ownership for generation; it does not register anything by itself. A method and function cannot both register the same model/query identity. `ErrDuplicate` reports the conflict. The full name is the logical model identity plus the query name, independent of the route. Package-private functions can be called by handwritten adapters in their own package without reflection. See [dependencies](/arc/go/backend/go/queries/model-bound/dependency-injection/) to add repository parameters without putting a service locator in business code. # Guard each observable emission > Suppress candidates or terminate subscriptions after authorization and detached interception. Admission alone cannot authorize everything a long-lived query will publish. Membership may change while a connection stays open. Arc rechecks membership and query declarations for every candidate; emission guards add your subscription- specific delivery policy after rendering and interception. They are not a replacement for authentication, tenant isolation or row selection. ## Register a lazy guard `builder.Queries().AddEmissionGuard(name, factory, keys...)` appends a uniquely named guard factory. Factories receive `context.Context` and `*execution.Scope`; optional exact dependency keys are checked at Build without activation. Factories activate only for authorized observable candidates, not ordinary unary queries. This composition excerpt assumes `builder` is an `*arc.Builder`, `context`, `execution` and `queries` are imported, and `mayReceive(ctx, c)` is your concurrent-safe policy function returning `(bool, error)`: ```go err := builder.Queries().AddEmissionGuard("subscription-access", func(context.Context, *execution.Scope) (queries.EmissionGuard, error) { return queries.EmissionGuardFunc(func(ctx context.Context, c queries.EmissionContext) (queries.EmissionVerdict, error) { allowed, err := mayReceive(ctx, c) if err != nil { return queries.DenyAndTerminate, err } if !allowed { return queries.DenyAndTerminate, nil } return queries.Allow, nil }), nil }) if err != nil { return err } ``` The policy runs under subscriber metadata, not the publisher’s identity/tenant. A callback error, panic or unknown verdict fails closed; raw errors stay local. ## Choose a verdict | Verdict | Effect | | --- | --- | | `Allow` | Continue to later guards and delivery | | `Suppress` | Skip this candidate, still run later guards that might deny | | `DenyAndTerminate` | End this observation with an unauthorized result | Guards run in registration order. Deny wins immediately. Suppression does not satisfy first-result waits, advance the first-delivered flag or commit a delta baseline. A later allowed candidate compares with the last delivered value. Direct streams send a terminal unauthorized QueryResult; hubs send Unauthorized for only the current subscription owner. No later data follows its terminal frame. ## Capture only subscription data `EmissionContext` exposes `Name`, `Arguments`, `Principal`, `Tenant`, `CorrelationID`, `QueryContext`, `FirstDelivered` and `SubscriptionScope`. `FirstDelivered()` means **no candidate has yet been delivered**, not that the current callback already succeeded. QueryContext contains the subscription’s receipt and stable correlation. An input filter can call `Invocation.SetSubscriptionScope(value)` before performer activation. The pipeline freezes it after filtering; input filters are not rerun on every emission. Arguments and scope data are reconstructed independently for each guard. Mutating one guard’s snapshot cannot change later guards or emissions. Scope data is JSON-round-trippable data, never a dependency resolver or resource lease. Streams, functions, channels, cycles and unrepresentable private state are rejected; nesting is bounded. Do not put request headers, bodies or a subscribe POST’s disposable resources into it. Use the owned execution scope for declared services and ensure collaborators remain available until the observation joins. ## Preserve privacy before transfer The candidate order is authorization → detached rendering → read-model interception → guards → encoding/transfer → acknowledged delivery. This uniform path applies to current snapshots, waits, subject and enumerable emissions, direct transports and both hubs; it deliberately closes reference-path gaps. Select authorized rows before counting/paging. Field masking cannot protect `totalItems`. Delta removals contain previously delivered intercepted items, never a newly resolved unmasked provider value. See [read-model interception](/arc/go/backend/go/queries/read-model-interception/) and [change-stream baselines](/arc/go/backend/go/queries/change-stream/). # Inspect observable queries over HTTP > Read current values, wait for a first result, or stream full results with SSE and WebSocket. You can inspect a live query without writing a frontend. These examples assume a running application at `http://localhost:8080` with the observable task feed registered at `/tasks/all` in [Keep a query live](/arc/go/backend/go/queries/observable-queries/). Supply your application’s credentials when the query is protected. The locked Node client lane executes both direct transports; browser compatibility remains unverified. These are the implemented transport contracts. ## Read or wait for a snapshot ```bash curl -i 'http://localhost:8080/tasks/all' curl -i 'http://localhost:8080/tasks/all?waitForFirstResult=true&waitForFirstResultTimeout=0.25' ``` A current value returns a ready QueryResult. A pending feed returns 202 without inventing data. Waiting attaches atomically and returns the first candidate that passes authorization, interception and guards; suppressed values do not satisfy it. A ready nil value omits `data` but is still ready, unlike pending. | Situation | Status | | --- | --- | | Allowed current value or first allowed emission | 200 | | No allowed current value and no wait | 202 | | First-result wait expires | 408 | | Source completes before an allowed value | 500 | | Authorization denied | 403 | | Authentication rejects credentials | 401 | | Enumerable-only registration without streaming | 400 | Wait keys are case-insensitive; duplicates fail rather than selecting one. `waitForFirstResult` accepts only Boolean `true`/`false` text, not `1`. Timeouts use positive finite seconds, including fractions. Invalid/nonpositive values use 30 seconds; `Observable.MaximumWait` caps the budget at five minutes by default. Timeout text includes the applied budget: ```text Timed out waiting 30 seconds for the first observable query result. ``` Normal completion before a value reports: ```text Observable query completed before producing its first result. ``` Enumerable-only snapshot requests return the reference’s exact 400 body: ```json {"message":"AsyncEnumerable queries require WebSocket connection"} ``` Despite that wording, direct SSE is also available. HEAD checks authorization, filters and request validity but never invokes the performer, opens a source, waits or upgrades. Ordinary snapshot-query HEAD behavior is unchanged. ## Stream with direct SSE ```bash curl -N -H 'Accept: text/event-stream' 'http://localhost:8080/tasks/all' ``` Each frame is `data:` followed by one space, one complete QueryResult JSON and two newline bytes. There is no named event, SSE ID, revision or hub envelope. The first result flushes immediately; a pending feed flushes headers without a synthetic result. Every later result is a full snapshot, never a hub delta. Responses use `text/event-stream; charset=utf-8` and `X-Accel-Buffering: no`. Framework `/.cratis/` streams keep `Cache-Control: no-store, private`, ordinary GET direct SSE uses `Cache-Control: no-cache`, and direct observable QUERY requests keep `Cache-Control: no-store`. An existing `Cache-Control` survives only when it has a `no-store` directive, so application middleware cannot leave a cacheable policy such as `private, max-age=600` on a stream. HTTP/1.x includes `Connection: keep-alive`; HTTP/2 does not. Direct SSE has no periodic keepalive enabled. A live denial sends one final unauthorized result then ends the stream. Source/encoding failures can send a safe terminal error; completion simply closes without an invented Completed event. QUERY can negotiate SSE too. URL wait controls remain separate from its argument body, and QUERY retains `Cache-Control: no-store`. Proxies must preserve flushing and disable buffering; embedded response wrappers must support `http.ResponseController` through `Unwrap` or the required optional interfaces. ## Use direct WebSocket A valid WebSocket GET upgrade takes priority over SSE Accept negotiation. Otherwise an observable GET/QUERY with SSE Accept streams; plain requests use snapshot semantics. A snapshot-only query never turns into a live query because of an Accept header. Direct WebSocket sends complete text messages with exactly `type: "Data"` and `data: `. There is no Connected, queryId, revision or payload envelope. Application Ping/Pong is JSON with `type` and a Unix-millisecond `timestamp`; Pong echoes a supplied Ping timestamp. RFC ping/pong and fragmented messages are handled by the transport library, not substituted for this protocol. Compression is disabled. Pre-upgrade denial remains HTTP 403. Live denial is a final Data-wrapped unauthorized result followed by orderly close. Direct transports always use full results; choose the [hub](/arc/go/backend/go/queries/observable-query-hub/) for multiplexing and deltas. ## Disconnect and recover A write succeeds only after local write/flush, not browser acknowledgement. Streaming clears the absolute unary write deadline and applies `Observable.WriteTimeout` to each write (ten seconds by default). Source cleanup has a five-second initial grace. A timeout is not proof that cleanup joined. Reconnect creates a new subscription and current snapshot. There is no Last-Event-ID resume, replay cursor, durable receipt or exactly-once promise. Bound retries in your client; do not reconnect indefinitely after a terminal Unauthorized. # Keep a query live > Register an owned source, publish current values, and consume observable query results in Go. Polling a list repeatedly wastes requests and still leaves your screen behind the latest state. An observable query keeps one admitted subscription open and sends new results as your application publishes them. You do not need Chronicle or a container. This is an experimental Go API. A locked Node lane executes the real Arc client transports; generated observable adapters and browser/React-hook compatibility remain separate, unverified boundaries. ## Register an application-owned feed Use `queries.RegisterObservable[M,A,O]` with a performer returning `observable.Source[O]`. `M` owns the query; `O` is the emitted value, not the source itself. Type inference supplies `A` and `O` when you specify only `M`. This registration excerpt assumes a read-model struct `Task` with `ID` and `Title` fields, an existing `*arc.Builder` named `builder`, and the `context`, `metadata`, `observable` and `queries` imports from Arc.Go: ```go state, err := observable.NewState([]Task{}, observable.SubjectOptions[[]Task]{Buffer: 16}) if err != nil { return err } err = queries.RegisterObservable[Task](builder, "All", queries.Function( func(context.Context, queries.NoArguments) (observable.Source[[]Task], error) { return state, nil }, ), queries.WithPath[queries.NoArguments]("/tasks/all"), queries.WithAuthorization[queries.NoArguments](metadata.Authorization{AllowAnonymous: true})) if err != nil { return err } ``` The initial empty slice is a ready result with `data: []`. Use `NewPendingState` when no first value exists yet. The performer runs after admission, once per subscription. You can delegate that closure to an unnamed value-receiver namespace method just as with [snapshot queries](/arc/go/backend/go/queries/). `AllowAnonymous` is explicit example policy, not a tenancy or row-access grant. Publish through `state.Publish(ctx, tasks)` and check the returned error. Each subscriber gets an independently owned stream; disconnecting one subscriber does not complete the application-owned state. `Complete` ends the source normally; `Fail` ends it with an error. A real nil or zero value is an emission, not EOF. ## Choose the source lifetime | Source | Use it when | | --- | --- | | `NewState(initial, options)` | You already have a current value; Open atomically attaches and replays it | | `NewPendingState(options)` | The first value will arrive later | | `NewSubject(options)` | You need emissions without a retained current value | | `FromProducer` | You have a cancellation-aware producer callback | | `FromIterator` | You have an iterator with an owned early-stop path | | `FromChannelFactory` | You can supply a channel and explicit producer cleanup/join | Construction starts no I/O or goroutine. An opened stream is single-consumer; `Next(ctx)` returns `io.EOF` only for normal completion. `Close(ctx)` cancels and joins the subscription. A timed-out close remains owned and needs a later join. Bare borrowed channels are not sufficient lifecycle-bearing query declarations. If a channel factory returns cleanup alongside a startup error or invalid channel, Open still returns an owned stream with that error. Close the nonnil handle using a separate cleanup budget; cancellation alone never joins its producer. The query pipeline retains incomplete failed-opening cleanup for later Shutdown, including when cleanup panics. `observable.ErrJoinPending` means completion is unknown or incomplete; retain the handle and its dependencies. A channel factory’s cleanup callback must allow serialized continuation after a context error, this sentinel or a panic. Initiate cancellation/disposal side effects at most once inside the callback; subsequent attempts only finish joining the same producer. Any other returned outcome certifies completion, including failure, and is cached rather than retried. A recovered panic remains inspectable as `execution.PanicError` after a successful later join, but no longer carries `ErrJoinPending`. `WithEnumerable[A]()` marks a streaming-only source, including the reference’s direct null-skipping behavior; ordinary subject nils remain ready emissions. Published slices, maps and pointed-to values must remain immutable. Set `SubjectOptions.Clone` when your application cannot transfer an immutable value; a shallow slice copy is insufficient for mutable nested fields. The pipeline detaches each candidate before rendering and interception, but that cannot make concurrent publisher mutation safe. Subject overflow terminates only the slow subscriber. A publish may reach some subscribers and fail for others; inspect `observable.PublishError` and do not blindly retry a partially successful publish. There is no silent drop-oldest policy or automatic coalescing. ## Consume from Go `queries.Perform[R]` reads an observable current/pending snapshot. Add `Request.WithWait(queries.WaitOptions{ForFirstResult: true})` to wait for the first allowed result. [HTTP snapshot and wait rules](/arc/go/backend/go/queries/observable-http/) use the same pipeline. `queries.Subscribe[R](ctx, pipeline, name, request, callback)` synchronously owns open, full-result delivery and close. The callback returns nil only when your consumer accepts delivery; an error stops consumption. Cancellation stops the observation and joins its cleanup. No caller-created goroutine is required. For explicit ownership, obtain the additive `queries.ObservablePipeline` capability, call `Open`, and then `Observation.Run`. Check both the admission result and local error; a nil observation is not successful activation. Run is single-use. You must call `Close` with a bounded cleanup context, never from the delivery callback. A custom snapshot-only pipeline returns `ErrObservableCapability`; observable `PerformScoped` is unsupported rather than borrowing a command’s shorter lifetime. ## Stop safely and isolate tenants Resolve the tenant’s authorized feed after admission. A global unfiltered subject plus field masking is not row authorization and can leak counts. Every candidate rechecks membership and authorization, then runs interception and [emission guards](/arc/go/backend/go/queries/observable-emission-guards/). `Application.Shutdown(ctx)` stops admission and cancels/joins observations and hub connections before draining unary work or stopping user hooks. If work does not join within the budget, the application remains Stopping; a later Shutdown continues cleanup. Do not dispose borrowed collaborators after a timeout. Next, [inspect a query over HTTP](/arc/go/backend/go/queries/observable-http/) or choose [hub transfers](/arc/go/backend/go/queries/observable-query-hub/) for multiplexing and collection deltas. # Observable query hub contract > Multiplex query subscriptions with revision ownership, bounded delivery and SSE control ownership. Use a hub to carry several subscriptions over one physical connection. Both hubs support full, delta and legacy collection transfer. The JavaScript runtime’s default is one WebSocket hub with delta transfer. The locked Node client lane executes that default and both hubs’ Full/Delta/Legacy transfers. Browser and React-hook reconstruction remain unverified; the Node delta case uses an independent test-consumer reducer, not a React hook. ## Routes and opening | Method | Route | Purpose | | --- | --- | --- | | GET | `/.cratis/queries/ws` | WebSocket hub upgrade | | GET | `/.cratis/queries/sse` | SSE hub stream | | POST | `/.cratis/queries/sse/subscribe` | SSE subscribe control | | POST | `/.cratis/queries/sse/unsubscribe` | SSE unsubscribe control | These framework routes do not depend on API prefixes or discovery exposure. HEAD on either hub is a nonstreaming probe that allocates no connection. Connected is written before activating subscription data: ```json {"type":"Connected","payload":"opaque-connection-id","keepAliveIntervalMs":30000,"supportsSubscriptionRevisions":true} ``` The ID is generated per physical connection, not an authentication credential. Hub SSE wraps each message in `data: \n\n`. Keepalives are JSON Ping hub messages, not SSE comments; there is no SSE ping endpoint or Pong control. WebSocket application Ping/Pong uses Unix milliseconds and echoes timestamps. ## Subscribe and unsubscribe WebSocket control uses `payload`. This example assumes the registered query’s fully qualified identity is `Task.All`: ```json {"type":"Subscribe","queryId":"tasks","revision":1,"payload":{"queryName":"Task.All","arguments":{},"transferMode":"delta"}} ``` SSE uses `connectionId` and `request`, not the WebSocket payload envelope: ```json {"connectionId":"opaque-connection-id","queryId":"tasks","revision":1,"request":{"queryName":"Task.All","arguments":{},"transferMode":"delta"}} ``` Arguments are strings or null, not arbitrary JSON QUERY arguments. Paging/sorting uses flat `page`, `pageSize`, `sortBy` and `sortDirection` fields. Hub sorting requires both sorting fields; direction `desc` is case-insensitive, otherwise ascending. Transfer mode is case-insensitive; missing or unknown text selects legacy, not delta. Unsubscribe uses the **current** revision, not necessarily a newer one: ```json {"type":"Unsubscribe","queryId":"tasks","revision":1} ``` ```json {"connectionId":"opaque-connection-id","queryId":"tasks","revision":1} ``` Supplied revisions must be lexical integers from 1 through 9007199254740991. Strings, exponent notation, fractions, zero and overflow fail. Absent/null means legacy. A newer subscribe replaces its owner; duplicate/older revisions do nothing. Equal/newer unsubscribe cancels and keeps a tombstone, even before any subscribe. Once an ID is revision-aware, legacy controls cannot override it. Go retains bounded high-water marks for the **connection lifetime**, unlike C#‘s expiring tombstones. IDs are not silently evicted at capacity. Reconnect creates fresh revision state and a full baseline; it does not resume. ## Results and failures QueryResult messages have `type`, `queryId`, optional `revision` and `payload` containing the complete QueryResult. Unauthorized has no payload. Error contains a safe string payload. Output type names use exact PascalCase; absent optional fields are omitted rather than null. One subscription’s denial/error does not terminate its siblings. No later frame may follow that owner’s terminal frame. SSE controls return empty bodies, including failures: | Condition | Status | | --- | --- | | Accepted, duplicate, stale or compatible no-op | 200 | | Invalid required fields or malformed JSON | 400 | | Unknown/wrong-owner connection | 404 | | Initial subscription denial, also sending Unauthorized | 401 | | Body too large | 413 | | Capacity admission rejected | 429 | | Application stopping | 503 | Unknown queries/source failures send safe hub Error messages. WebSocket malformed or binary controls close the physical connection rather than creating a command result. Go rejects case-insensitive duplicate JSON members, including Unicode simple-fold equivalents. ## Own SSE controls securely SSE controls must match the GET’s verified subject, authentication state and tenant (including tenant presence). Wrong ownership returns 404 without exposing the connection. WebSocket subscriptions use the handshake identity/tenant; arbitrary socket data cannot refresh credentials. Subscription metadata/resources have their own lifetime and do not borrow the short-lived POST request scope. Anonymous SSE uses a different random HttpOnly cookie for each connection, SameSite=Strict, Secure on TLS, no Domain. It covers the hub and its controls and expires with the bounded connection lifetime. Keep a cookie jar for terminal use: ```bash curl -N -c cookies.txt 'http://localhost:8080/.cratis/queries/sse' ``` After reading Connected, use another terminal, replace the connection ID and query identity, and retain the same cookie jar: ```bash curl -i -b cookies.txt -H 'Content-Type: application/json' \ -d '{"connectionId":"opaque-connection-id","queryId":"tasks","revision":1,"request":{"queryName":"Task.All","arguments":{},"transferMode":"delta"}}' \ 'http://localhost:8080/.cratis/queries/sse/subscribe' ``` A 200 response has an empty body; the result appears on the GET stream. The cookie is ownership evidence, not user authentication. `Observable.AnonymousOwner` can supply verified host/session evidence instead. Parallel connections use different cookie names; eligible later hub responses expire closed cookies. Same-origin policy uses actual TLS and Host, never untrusted forwarded headers. `Observable.AllowedOrigins` adds exact origins, not wildcard credentials or CORS middleware. `Origin: null` requires explicit opt-in. Cross-origin use also needs host-owned credentialed CORS/session configuration; built-in Strict cookies target the same-origin path. Browser WebSocket/EventSource cannot generally add arbitrary credential headers; provide a compatible host authentication mechanism. ## Limits and shutdown Zero observable option fields select these defaults: | `ObservableOptions` field | Default | | --- | --- | | `MaxObservations` | 1024 application operations, including opening/unjoined | | `MaxConnections` / `MaxConnectionsPerOwner` | 256 / 8 hub connections | | `MaxSubscriptions` / `MaxQueryIDs` | 64 outstanding operations / 1024 IDs per connection | | `MaxOpenings` / `MaxOpeningsPerConnection` | 32 / 4 | | `MaxOutboundJobs` | 64 queued/in-flight jobs per connection | | `MaxQueuedBytes` / `MaxStreamingBytes` | 32 MiB connection / 256 MiB application retained hub bytes | | `WriteTimeout` / `CloseGrace` | 10 seconds / 5 seconds | | `KeepAliveInterval` / `ConnectionLifetime` | 30 seconds / 12 hours | | `MaximumWait` | 5 minutes | `HTTP.MaxBodyBytes` defaults to 1 MiB for controls; `HTTP.MaxResponseBytes` defaults to 16 MiB for encoded frames and each hub baseline. Query IDs are at most 256 bytes, query names 1024 bytes, arguments 128 entries. Frame/baseline candidates and delivered baselines share connection/application byte budgets. Queue/byte exhaustion closes the slow connection; an oversized emission terminates its subscription with safe diagnostics. Opening/retired workers count until they join, so replacement cannot bypass limits. Limits cannot bound application callback allocations. There is no silent delta loss or drop-oldest queue policy. One writer serializes frames and keepalives. Queue admission is not delivery; only successful local write/flush advances the [collection baseline](/arc/go/backend/go/queries/change-stream/). An already-started write cannot be retracted by replacement; revision-aware clients must discard obsolete revisions. Legacy replacement fences earlier writes. Application shutdown cancels and joins hub readers, writers and subscriptions, including hijacked sockets, before user hooks. Hub WebSocket shutdown is a bounded disconnect, not a promised graceful close frame. A failed join leaves Stopping and can be continued with a later Shutdown. # Protected query health > Enable an opt-in, role-protected observable query that reports aggregate observable-query and hub connection counts. Observable queries hold connections, subscriptions and source resources open. When an instance misbehaves you want to know how many are opening, running, closing or stuck in cleanup, without exposing who is connected or what they asked for. Arc.Go offers that as an opt-in framework query at `/.cratis/queries/health`. C# Arc’s `Queries/QueryHealth.cs` and `QueryHealthTracker.cs` (Arc `7c1e780`) expose a detailed health topology anonymously. Arc.Go deliberately differs. Health is **absent by default in every environment, protected by administrative roles, and aggregate only**. Status: **Partial**. ## Enable the endpoint `ExampleQueryHealth` in `query_health_example_test.go` is the complete compiled example. Supply at least one role: ```go builder, err := arc.NewBuilder(arc.Options{ QueryHealth: &arc.QueryHealthOptions{Roles: []string{"Operations"}}, }) if err != nil { panic(err) } application, err := builder.Build() if err != nil { panic(err) } if err := application.Start(context.Background()); err != nil { panic(err) } // Backend code supplies a trusted identity. HTTP callers instead require an // Authentication handler that verifies credentials and supplies this role. ctx := identity.WithPrincipal(context.Background(), identity.System("Operations")) result, err := queries.Perform[arc.QueryHealth](ctx, application.Queries(), arc.QueryHealthName, queries.Request{}) if err != nil { panic(err) } health, present := result.Data() fmt.Println(result.IsSuccess(), present, len(health.Observations)) ``` It prints `true true 0`. Run it with: ```bash go test -run ExampleQueryHealth . ``` `Roles` is copied. It must contain at least one nonempty role without surrounding whitespace and at most 512 bytes; otherwise `NewBuilder` fails. Any one matching role on an authenticated principal grants access. Leaving `Options.QueryHealth` nil registers nothing: the path returns 404, even in Development. ## Read it over HTTP | Request | Result | | --- | --- | | `GET /.cratis/queries/health` with a permitted role | 200 and a query result envelope whose `data` is the current sample | | `HEAD` with a permitted role | 202 with no body; like other observable HEAD requests it does not activate the source | | `QUERY` | Same as GET when `Routes.EnableQueryHTTPMethod` is on (the default); 405 when it is off | | `Accept: text/event-stream` | Server-Sent Events stream: the current sample, then one sample per second | | Missing or wrong role | 403 with an unauthorized envelope; no state is read | Every response, including streams, uses `Cache-Control: no-store, private`. The health query is also available through the observable hubs under `QueryHealth.ObserveHealth` (`arc.QueryHealthName`). Hub subscriptions without a permitted role receive an `Unauthorized` message. The endpoint is excluded from discovery catalogs. You need an authentication handler that verifies credentials and supplies the role. See [authentication handlers](/arc/go/backend/go/authentication/). ## What a sample contains `arc.QueryHealth` serializes as: | Member | Meaning | | --- | --- | | `observations` | One `queries.ObservationHealth` (`query` label, `opening`, `running`, `closing`, `retained`, `delivered`) for each registered label or `_other` that currently owns at least one observation; labels with no owned observations are omitted, and a label disappears once its final observation joins | | `hubs` | One entry each for `sse` and `websocket` with `connected` and `closing` physical connections | | `openings` | Hub subscriptions still in admission or source opening | | `operations` | Active plus retired-but-unjoined hub subscriptions | `retained` counts observations whose cleanup has not confirmed a join, which is the number to watch for leaked sources. `delivered` counts acknowledged data results for the currently owned observations, not historical traffic. A sample never contains connection or query IDs, principals, client details, arguments, cached results or exception messages. Application observations exclude the health query itself. Hub operations and observations overlap, so do not add them together. Each owner is sampled under its own lock; the sample as a whole is not globally atomic. ## Isolation from your application The health query runs in a private pipeline. It never activates your request readers, resource openers, scope factories, filters, validators or policies, and it rechecks the role on every emission. Streaming uses the consumer’s own one-second timer, so no background publisher runs. Health observations count against a separate ceiling equal to `Observable.MaxObservations`. For per-artifact counts and durations, add [backend diagnostics](/arc/go/backend/go/core/diagnostics/); the health pipeline records into the same recorder. ## Evidence `query_health_test.go` and `ExampleQueryHealth` cover absence without opt-in, role validation and copying, private-pipeline isolation from application factories and readers, one-second sampling that excludes itself, hub role enforcement with aggregate-only data, private no-store streaming and QUERY route gating. See the [parity map](/arc/go/parity/) for the deliberate differences from C#. # Query pipeline > Understand snapshot stages, operation-scope ownership and fail-closed publication. Query execution separates caller binding from dependency activation. A malformed argument or denied caller must not construct the performer’s ordinary dependencies. The pipeline owns snapshot publication and cleanup, not transactions or retries. ## Execution order 1. Resolve the registered logical name; establish correlation and a fresh receipt without changing principal/tenant presence. 2. Validate active paging and bind declared inputs atomically. 3. Prepare authorization, require the tenant if configured, and open/admit resources. 4. Install `QueryContext`; enforce tenant membership and declaration authorization. 5. Run authorization filters, then ordinary filters and argument-model validators. 6. Recheck authorization/security and invoke the performer. 7. Render, install the total, intercept items, and recheck cancellation/security. 8. Dispose owned resources and finalize once, retracting data and changes on failure. All retained validation findings block queries, including information and warnings. Snapshot results are ready; successful nil is ready-null. Failure never manufactures pending state. Existing result constructors remain independent of pipeline finalization. ## Observable delivery `RegisterObservable` reuses admission, then owns one source stream and its resources for the observation lifetime. Current/pending/wait snapshots do not force pending success to ready. Every stream candidate rechecks authorization/membership, detaches its data, renders/intercepts, runs emission guards and waits for delivery acknowledgement. Input filters run once. Suppression never advances the delivered baseline. The additive `ObservablePipeline` provides Open and CloseObservations without extending custom snapshot-only Pipeline implementations. Run is single-use and synchronous; caller-created observations require Close. Failed opening cleanup remains counted until joining. Scope disposal is at most once; only an explicit `execution.ResourcesJoiner` may resume its join after a timeout. Ordinary completed disposal failures remain errors but do not retain an observation forever. See [observable queries](/arc/go/backend/go/queries/observable-queries/), [emission guards](/arc/go/backend/go/queries/observable-emission-guards/) and [collection baselines](/arc/go/backend/go/queries/change-stream/). Observable PerformScoped is explicitly unsupported; it cannot borrow a command’s shorter operation lifetime. ## Registries and extensions `Registry` is a single-owner builder; its zero value uses the global namespace. Successful Build freezes it, while failed Build leaves it editable. Build does not activate performer, filter, validator, renderer or interceptor factories. `AddAuthorizationFilter` and `AddFilter` append named factories. Filters contribute verdict fragments only: data or change sets are rejected. A zero fragment denies; continue with an explicitly authorized `NewResult` fragment without data. Factories return borrowed instances, and shared instances must support concurrent calls. `ContextFrom(ctx)` returns immutable query metadata: name, correlation, receipt, principal, tenant, raw arguments, controls and the post-render total. `Invocation.Scope()` exposes only an expiring, non-closing callback view. No provider or execution owner is stored in context. ## Scope ownership `Perform` always opens independent resources. Nil `OpenResources` opens a valid empty scope. `PerformScoped` borrows compatible admission and never closes the supplied scope. Sharing a command’s resources does not enlist a query in command completion or make the query a transaction participant. Close stops new stage admissions and joins entered callbacks before disposal. A query interrupted by scope shutdown fails rather than publishing partial data. The implementation launches no goroutines. Callbacks are synchronous, must honor cancellation and must not retain scope views or dependencies beyond their owner. Cleanup uses a detached, value-preserving context with a cooperative bounded budget: zero means 30 seconds; negative budgets fail Build. No detached cleanup goroutine pretends to force a callback to stop. ## Errors and safe publication Returned local errors preserve `errors.Is`/`errors.As` and joined cleanup causes. Recognized validation failures become findings; validator infrastructure contracts retain `validatorFailed` and `The value could not be validated.` Cancellation is not validation. Application panics become `execution.PanicError`. Production exceptions, including exception-bearing fragments, are redacted to `An internal error occurred while processing the request. See server logs for details.` `ExposeExceptionDetails` is an explicit development option. A nil logger performs no automatic logging. Findings remain caller-authored safe text. No data or change set survives denial, findings, exceptions, cancellation or cleanup failure. That does not prove an external write rolled back or never committed. See [result precedence](/arc/go/backend/go/queries/results/) and [reader failures](/arc/go/backend/go/queries/using-the-http-query-method/) before mapping these outcomes to HTTP. # Read-model interception > Transform rendered models in deterministic order without treating masking as row authorization. Use interception when a returned model needs a safe presentation transformation, such as removing a private field. Render first, then transform each returned item. Do not use masking as row authorization: a renderer may already have counted rows. ## Register an exact transformation `RegisterReadModelInterceptor[T](registry, name, factory)` appends a named factory. It supplies a `ReadModelInterceptor[T]`; `InterceptorFunc[T]` adapts an ordinary `func(context.Context, T) (T, error)`. The executed masking example is `ExampleRegisterReadModelInterceptor` in `queries/example_test.go`. Pointer and value registrations are exact and independent. An interceptor for `Item` does not automatically intercept `*Item`. There is no runtime closure of open generic interceptors. Duplicate names for the same exact type fail. ## Order and ownership Applicable factories activate only after rendering. Each item flows through interceptors in registration order, and items run sequentially in collection order. This deliberately differs from C#‘s concurrent item processing while preserving result order. Nil single models and nil pointer elements skip item transformation. For ordinary snapshots, collection membership is copied before transformation, not deeply cloned. Return safe transformed models; a pointer interceptor that mutates a shared repository model can still expose a race. Observable candidates are detached before rendering/interception and again before retention, so one subscriber’s masking cannot mutate another’s source view. Publishers must still honor the source’s immutable-value or clone contract. Shared callbacks must be concurrent-safe across independent query executions. A factory error, panic, cancellation or item failure retracts the whole snapshot. No partly masked collection is published. Interceptors cannot secure pre-render counts; apply row authorization at the selection source. Observable [emission guards](/arc/go/backend/go/queries/observable-emission-guards/) run after interception, and [delta removals](/arc/go/backend/go/queries/change-stream/) use previously delivered masked items. # Query results > Represent ready, pending, paged and delta query outcomes without losing null semantics. A query result separates **no value yet** from a value that happens to be null. Use `queries.Success(correlationID, data)` for an emitted value and `queries.NotReady[T](correlationID)` for an observable that has not emitted. Neither starts a query or subscription. ## Readiness and status `queries.NewResult(details, data)` accepts explicit `queries.Details` and `serialization.Optional[T]`. Set `Authorized` and `Ready` intentionally: their zero values are false. `Data()` reports local presence independently of readiness. A successful nil value is ready, but `data` is omitted from JSON. `IsSuccess` requires readiness, authorization, no validation findings and no exception messages. Standard status selection is **200**, **403**, **400**, **202** (not ready), then **500**, in that order. Like C#‘s helper, a contradictory not-ready exception result still selects 202; normal producers should not construct that state. HTTP hosting separately selects **401** for credential rejection and **400** for QUERY reader failures, even when they carry exceptions. First-result waits and **408** remain unsupported; snapshot reads never pretend to wait. `StatusCode()` is not a universal HTTP error mapper. ## Paging and change sets `PagingInfo` always appears as `{ page, size, totalItems, totalPages }`. Its zero value is not paged. `Page` is zero-based. `TotalPages()` uses the C# double-precision ceiling; zero size gives zero total pages. Page/size are int32, total items is int64. Page-count overflow returns MinInt32 deterministically; this is not a claim about a particular C# runtime’s unchecked overflow behavior. This is response metadata only: no request reader, sorting, validation or automatic slicing runs here. `Details.ChangeSet` optionally supplies `queries.ChangeSet` with `Added`, `Replaced` and `Removed` item arrays. Empty arrays always serialize as `[]`. Removed entries are full items, not just IDs. This DTO does not compute differences, maintain a baseline or implement SSE/WebSocket framing. ## Serialization and ownership Query envelopes always include correlation, flags, required validation/exception arrays, stack string and paging. Unlike commands, they never include `authorizationFailureReason`. Null `data` and `changeSet` are omitted; scalar zeros and empty non-nil collections are preserved. Constructors and `Details()` copy framework-owned arrays and change-set slices. Application data, change-set items and validation state are borrowed; do not mutate them during concurrent use. Results are output-only values, not remote JSON decoders. See [command results](/arc/go/backend/go/commands/results/), [concept codecs](/arc/go/backend/go/concepts/) and [the parity map](/arc/go/parity/) for the exact implemented boundary. # GET and HTTP QUERY readers > Normalize GET controls and QUERY envelopes while preserving safe parse errors and no-store metadata. Use `ReadGET(url.Values)` or `ReadQUERY(body)` to normalize transport data before pipeline admission. These are request readers, not HTTP handlers. Hosting must limit body size and apply status/cache metadata; these functions do not expose routes. ## QUERY envelope ```json { "arguments": { "category": "books", "ids": [1, 2] }, "paging": { "page": 0, "pageSize": 10 }, "sorting": { "field": "name", "direction": "ascending" } } ``` The body must contain exactly one object. Null/array roots, trailing JSON values, case-insensitive duplicate declared envelope/control members and duplicate argument names fail. Unknown envelope members are permissive. Argument raw JSON is retained; conversion waits until the typed registration is known. Paging values must be integer tokens within int32 range; decimal/exponent tokens, strings and null integer members fail parsing. Positive size enables paging. Omitted/null sort direction defaults ascending; explicit empty is invalid when sorting is active. Arrays preserve scalar element boundaries. Nested scalar elements fail binding; JSON-node collections require explicit custom bindings. ## GET controls Reserved names are case-insensitive: `page`, `pageSize`, `sortby`, `sortDirection`, `waitForFirstResult`, and `waitForFirstResultTimeout`. Repeated or case-folded duplicate controls fail; wait controls are excluded from arguments. Observable registrations support bounded first-result waits with Boolean text and positive finite seconds; ordinary snapshots remain unary. For QUERY, the body supplies arguments while URL wait controls remain separate. See [observable HTTP](/arc/go/backend/go/queries/observable-http/). Repeated caller collections collapse through comma splitting and trimming. Parsed int32 size enables paging even for zero/negative size; active pipeline validation then rejects invalid page/size. Invalid/missing page defaults zero. Sorting requires both nonempty field and direction. Only asc/ascending/desc/descending are accepted, case-insensitively. ## Reader metadata and errors `QueryStringRequestReader` implements method GET with empty cache-control metadata. `BodyRequestReader` implements QUERY with `ResponseCacheControl() == "no-store"`. Hosting must send that header on **success and reader failure**, and separately map malformed QUERY syntax to 400. Do not rely solely on result status precedence. `ReadError.Malformed` distinguishes syntax/envelope errors from semantic sorting findings. The original parser cause remains inspectable. `ArgumentError` carries safe `rule` or `malformedRequest` findings; `SortingError` carries a safe field finding. Production exception text must never expose parser input or private diagnostics. See [argument binding](/arc/go/backend/go/queries/model-bound/query-arguments/) for effective presence rules and [paging](/arc/go/backend/go/queries/model-bound/paging/) for provider behavior. # Query validation > Validate successfully bound argument models and block every retained query finding. Binding answers whether caller input can form an argument model. Validation answers whether that model is acceptable. Keep required transport presence in `query` tags, and put business rules in model methods or typed validators. ## Validation sources The default graph runs a matching `Validate(context.Context) ([]validation.Result, error)` model method, registered graph/concept validators, and the supported `validate:"required"` tag pass. Nil `PipelineOptions.Validation` still enables model methods and portable tags; a supplied graph adds its registrations. `WithValidator[A]` appends direct typed validators in option order. `WithScopedValidator[A]` appends lazy factories constructed only in the authorized validation stage. `WithoutModelValidation[A]` explicitly disables graph/model/tag validation for this query, not explicit validators or transport requiredness. See [shared validation](/arc/go/backend/go/validation/) for graph traversal, concept owning members, cycles and the bounded annotation subset. A Go model `Validate` method is an explicit Go convention, not a claim that C# automatically invokes such a method. ## Every retained finding blocks Queries have no allowed-severity relaxation. Unknown, information, warning and error findings all prevent performer dependency construction and data publication. Recognized `validation.Failure` findings stay safe and inspectable. Unexpected validator errors or panics produce `validatorFailed` with exactly `The value could not be validated.` Cancellation remains cancellation, not a rule. The graph is intentionally bounded: no arbitrary map traversal, reflective getters, full DataAnnotations emulation or client rule extraction is promised. Use explicit typed validators for unsupported structures. See [query arguments](/arc/go/backend/go/queries/model-bound/query-arguments/) for missing versus malformed input and [pipeline errors](/arc/go/backend/go/queries/query-pipeline/) for cleanup failures joined with validation errors. # Recipes for common Go hosts and libraries > Compiled, tested recipes for mounting Arc in Chi, Gin and Echo and adding JWT, CORS/CSRF, OpenTelemetry and go-playground validation. Arc is a plain `http.Handler`, so it fits into the router, authentication, tracing and validation libraries your service already uses. The glue is small, but each library has one detail that silently breaks Arc’s contract: a router that swallows the QUERY method, a fallback that appends a body to Arc’s empty 404, or a struct tag both libraries claim. These recipes show the glue that works and the tests that prove it. ## Recipes | You want to | Recipe | | --- | --- | | Mount Arc in a Chi router | [Chi](/arc/go/backend/go/recipes/chi/) | | Forward unmatched Gin requests to Arc | [Gin](/arc/go/backend/go/recipes/gin/) | | Delegate Echo v5 requests to Arc | [Echo](/arc/go/backend/go/recipes/echo/) | | Authenticate bearer tokens with golang-jwt v5 | [JWT bearer tokens](/arc/go/backend/go/recipes/jwt/) | | Serve a frontend on another origin safely | [CORS and CSRF](/arc/go/backend/go/recipes/cors-csrf/) | | Join callers’ traces with otelhttp | [OpenTelemetry](/arc/go/backend/go/recipes/opentelemetry/) | | Validate with go-playground/validator | [Validation](/arc/go/backend/go/recipes/validation/) | ## How the recipes are verified Every Go block on these pages is an exact excerpt of code in the repository’s [`recipes` module](https://github.com/Cratis/Arc.Go/tree/develop/recipes). Its tests serve a small Arc application through each recipe over real HTTP and check commands, `/validate`, GET and QUERY binding, HEAD, Arc’s empty 404/405 and request cancellation, correlation/privacy headers, direct SSE/WebSocket results, disconnect cleanup and application shutdown, plus each recipe’s own behavior. A documentation test fails when a Go block here no longer matches the tested code. Run them from the repository root with the module’s own manifest: ```bash cd recipes GOWORK=off go test ./... ``` The tests cover the library versions pinned in `recipes/go.mod`. Other versions may behave differently; run the same tests against yours before you rely on a recipe. ## What the module is not The `recipes` module is never tagged or published, and Arc’s runtime module does not depend on any of these libraries. Copy the code you need into your application; do not import `github.com/cratis/arc.go/recipes`. The recipes do not cover databases, message brokers or other optional integrations. # Mount Arc in Chi > Preserve Arc paths and QUERY dispatch when embedding in a Chi host. Your service already routes with Chi and you want Arc’s commands and queries beside your own handlers. Mount the Arc application at the root so Arc keeps its own paths, its custom QUERY method and its empty 404/405 responses. ## Mount the handler Build and start Arc first, then hand the application to the router. This code is compiled and tested in the [recipes module](/arc/go/backend/go/recipes/): ```go // NewRouter returns a Chi router that serves host routes and forwards every // other request, unchanged, to the started Arc application. func NewRouter(app http.Handler) chi.Router { // Chi v5.3.1 and later route QUERY natively, so this is a no-op there. // Earlier versions answer QUERY with Chi's own 405 unless it is registered // before Mount. Registration is process-global. chi.RegisterMethod("QUERY") router := chi.NewRouter() router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) router.Mount("/", app) return router } ``` `Mount` does not rewrite the request URL, so Arc sees the original path. Your own routes, such as `/healthz`, win for their exact method and path. Every other request reaches Arc, which answers unmapped paths with an empty 404 and a wrong method with an empty 405 and its own `Allow` header. Do not add a competing catch-all A Chi `NotFound` or `MethodNotAllowed` handler, or another `/*` route, can take requests before they reach Arc. Reserve Arc’s operation paths and `/.cratis/*`. ## What the recipe proves The test serves the router over real HTTP with Chi v5.3.2 and checks command execution, `/validate` without execution, GET and QUERY argument binding, HEAD, Arc’s empty 404/405, and that cancelling the client request cancels the query’s context. Direct SSE flushes two ordered results, direct WebSocket upgrades and sends two results, and disconnect and application shutdown join the source. Correlation IDs and QUERY’s `no-store` survive the host. It does not cover other Chi versions, hub multiplexing or Chi middleware you add. ## Own the server lifecycle Your host owns the listener and server timeouts. Initiate HTTP shutdown and Arc shutdown together, then join both with fresh bounded cleanup budgets. Do not wait for `server.Shutdown` before calling `app.Shutdown`: HTTP shutdown waits for active SSE handlers, while Arc shutdown cancels and joins their sources. Copy this helper into your host and check its returned error: ```go // ShutdownHost stops HTTP admission and Arc observations concurrently, then // joins both. Each has a fresh five-second cleanup budget, not the canceled // signal or request context. Business callbacks must honor cancellation. func ShutdownHost(app *arc.Application, server *http.Server) error { httpContext, cancelHTTP := context.WithTimeout(context.Background(), 5*time.Second) defer cancelHTTP() arcContext, cancelArc := context.WithTimeout(context.Background(), 5*time.Second) defer cancelArc() httpStopped := make(chan error, 1) go func() { httpStopped <- server.Shutdown(httpContext) }() arcError := app.Shutdown(arcContext) return errors.Join(arcError, <-httpStopped) } ``` The mounted-host tests run this sequence with an active SSE subscription and check both the drained response and producer cancellation. Configure CORS and authentication outside Arc as shown in [CORS and CSRF](/arc/go/backend/go/recipes/cors-csrf/) and [JWT bearer tokens](/arc/go/backend/go/recipes/jwt/). See [hosting](/arc/go/backend/go/core/hosting/) for the embedding contract. # Serve browsers on other origins > Protect Arc from cross-site requests and allow a trusted frontend origin with the standard library. Your React frontend runs on `https://app.example.com` and Arc runs on another origin. Browsers need CORS headers before they let the frontend read Arc’s responses, and Arc’s command endpoints must not accept a POST forged by another site that rides on the user’s cookies. The host supplies CORS and CSRF; Arc separately checks origins on observable WebSocket and hub SSE transports. This recipe configures that allow-list and wraps the application with the standard library’s `http.CrossOriginProtection` and a small CORS handler. ## Wrap the application This code is compiled and tested in the [recipes module](/arc/go/backend/go/recipes/). Before building Arc, set its observable origins to the same exact origins you will pass to `Protect`. Here `frontend` is `"https://app.example.com"`; pass `options` to `arc.NewBuilder`: ```go options := arc.Options{ Observable: arc.ObservableOptions{AllowedOrigins: []string{frontend}}, } ``` Without this builder option, trusted cross-origin direct and hub WebSockets and hub SSE still receive 403, even though CORS and CSRF allow the frontend. After starting the application, wrap it: ```go // Protect wraps a started Arc application. Cross-origin browser requests are // accepted only from the exact trusted origins, such as // "https://app.example.com"; every other cross-site unsafe request (POST, // QUERY) is rejected with 403 before Arc runs. At builder construction, also // set Observable.AllowedOrigins to these origins for WebSocket and hub SSE. func Protect(app http.Handler, trustedOrigins ...string) (http.Handler, error) { csrf := http.NewCrossOriginProtection() trusted := make(map[string]bool, len(trustedOrigins)) for _, origin := range trustedOrigins { if err := csrf.AddTrustedOrigin(origin); err != nil { return nil, err } trusted[origin] = true } return cors(trusted, csrf.Handler(app)), nil } // cors answers preflights itself (Arc returns 405 for OPTIONS) and lets // trusted origins read responses, including credentialed ones. func cors(trusted map[string]bool, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { header := w.Header() header.Add("Vary", "Origin") origin := r.Header.Get("Origin") if !trusted[origin] { next.ServeHTTP(w, r) return } header.Set("Access-Control-Allow-Origin", origin) header.Set("Access-Control-Allow-Credentials", "true") if r.Method != http.MethodOptions || r.Header.Get("Access-Control-Request-Method") == "" { header.Set("Access-Control-Expose-Headers", "X-Correlation-ID") next.ServeHTTP(w, r) return } header.Add("Vary", "Access-Control-Request-Method") header.Add("Vary", "Access-Control-Request-Headers") // QUERY is not CORS-safelisted, so browsers always preflight it. header.Set("Access-Control-Allow-Methods", "GET, HEAD, POST, QUERY") header.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Allowed-Severity, X-Ignore-Warnings, X-Correlation-ID, x-cratis-tenant-id") header.Set("Access-Control-Max-Age", "600") w.WriteHeader(http.StatusNoContent) }) } ``` Serve the returned handler instead of `app`. `CrossOriginProtection` reads the browser’s `Sec-Fetch-Site` header, or compares `Origin` with `Host` for older browsers. It treats GET, HEAD and OPTIONS as safe and checks every other method, so QUERY is checked like POST. ## What reaches Arc | Request | Result | | --- | --- | | Server-to-server, no `Origin` or `Sec-Fetch-Site` | Unchanged Arc behavior | | Same-origin browser request | Unchanged Arc behavior | | Cross-site POST or QUERY from the trusted origin | Arc runs; response carries `Access-Control-Allow-Origin` | | Cross-site POST or QUERY from any other origin | 403 before Arc; the command does not run | | Cross-site snapshot GET or direct SSE from any other origin | Arc runs; no CORS headers, so the browser hides the response | | Direct or hub WebSocket, or hub SSE, from the trusted origin | 101 upgrade or 200 stream when `Observable.AllowedOrigins` matches | | Direct or hub WebSocket, or hub SSE, from any other cross-origin site | 403 from Arc’s transport origin check | | Preflight from the trusted origin | 204 with the allowed methods and headers | | Preflight from any other origin | No CORS grant | Exact origins only Trust exact `scheme://host[:port]` origins. Never reflect an arbitrary `Origin` while also sending `Access-Control-Allow-Credentials: true`; that hands every site the user’s session. Add the headers your frontend sends to `Access-Control-Allow-Headers`. The test also runs the full mount contract through the wrapper: commands, `/validate`, GET, HEAD, QUERY, Arc’s empty 404/405, request cancellation, direct SSE/WebSocket and source cleanup on disconnect and application shutdown. Browser-origin tests also check trusted and attacker direct/hub WebSocket handshakes and hub SSE. Preflights allow `X-Ignore-Warnings`, and trusted responses expose `X-Correlation-ID` so JavaScript can read the correlation ID. The wrapper passes the original ResponseWriter through; it does not remove flushing or hijacking capabilities. Forwarded headers are not trusted or parsed; configure any proxy-derived identity or address handling separately at ingress. ## When this is the wrong fit If an ingress or API gateway already applies CORS and CSRF policy, do not add a second policy here; conflicting `Vary` and `Access-Control-*` headers are hard to debug. Requires Go 1.25 or later for `http.CrossOriginProtection`, which Arc.Go’s Go 1.26 minimum satisfies. # Mount Arc in Echo > Delegate every unmatched method and path from Echo v5 to Arc's http.Handler. Your service already uses Echo and you want Arc’s commands and queries beside your Echo routes. Echo v5’s `Any` route matches every method, including QUERY, so one wildcard route hands Arc everything your own routes do not claim. ## Delegate to Arc Build and start Arc first. This code is compiled and tested in the [recipes module](/arc/go/backend/go/recipes/): ```go // NewEcho returns an Echo instance that serves host routes and forwards every // other request, unchanged, to the started Arc application. func NewEcho(app http.Handler) *echo.Echo { e := echo.New() e.GET("/healthz", func(c *echo.Context) error { return c.NoContent(http.StatusNoContent) }) // Echo v5's Any matches every method, including QUERY. e.Any("/*", echo.WrapHandler(app)) return e } ``` Method-specific Echo routes, such as `GET /healthz`, take precedence over the `Any` route. Echo passes the original request to Arc, so Arc’s routes, empty 404/405 responses and `Allow` headers reach the client unchanged. Echo v4 is different The recipe targets Echo v5 (`github.com/labstack/echo/v5`). Echo v4’s `Any` registers a fixed method list; check whether your version includes QUERY and register it explicitly with `Add` if not. The recipe module does not test v4. ## What the recipe proves The test serves the instance over real HTTP with Echo v5.4.0 and checks command execution, `/validate` without execution, GET and QUERY argument binding, HEAD, Arc’s empty 404/405, and that cancelling the client request cancels the query’s context. Direct SSE flushes two ordered results, direct WebSocket upgrades and sends two results, and disconnect and application shutdown join the source. Correlation IDs and QUERY’s `no-store` survive the host. It does not cover other Echo versions, hub multiplexing or Echo middleware you add. ## Identity and lifecycle Use an Arc authentication handler such as the [JWT bearer recipe](/arc/go/backend/go/recipes/jwt/), or `authentication.HostPrincipal()` after your host has installed a verified Arc principal. Your host owns the listener and timeouts. Initiate `server.Shutdown` and `app.Shutdown` together, then join both with fresh bounded cleanup budgets, as in the [coordinated shutdown helper](/arc/go/backend/go/recipes/chi/#own-the-server-lifecycle). Waiting for HTTP drain first stalls active SSE handlers until the deadline; Arc must cancel their observations while HTTP drains. The Echo tests exercise this sequence with an active SSE stream. See [hosting](/arc/go/backend/go/core/hosting/) and the [HTTP contract](/arc/go/backend/go/reference/http-contract/). # Mount Arc in Gin > Forward unmatched Gin requests to Arc without losing QUERY or Arc's empty 404. Your service already uses Gin and you want Arc’s commands and queries next to your Gin routes. Gin has no wildcard that matches every method, so forward the requests Gin does not route itself to Arc. ## Forward unmatched requests Build and start Arc first. This code is compiled and tested in the [recipes module](/arc/go/backend/go/recipes/): ```go // NewEngine returns a Gin engine that serves host routes and forwards every // unmatched request, unchanged, to the started Arc application. func NewEngine(app http.Handler) *gin.Engine { engine := gin.New() engine.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusNoContent) }) engine.NoRoute(func(c *gin.Context) { app.ServeHTTP(immediateWriter{c.Writer}, c.Request) // Gin appends "404 page not found" to a NoRoute response that wrote // no body. Commit Arc's status so its empty 404 stays empty. c.Writer.WriteHeaderNow() }) return engine } // immediateWriter restores net/http's immediate header commitment. Gin defers // WriteHeader; Arc's wrappers hide Gin's WriteHeaderNow from WebSocket libraries, // so an unadapted 101 would remain buffered when the connection is hijacked. // Embedding keeps Gin's flush/hijack support and response accounting intact. type immediateWriter struct{ gin.ResponseWriter } func (w immediateWriter) WriteHeader(status int) { w.ResponseWriter.WriteHeader(status) w.WriteHeaderNow() } func (w immediateWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } ``` Gin sends a request to `NoRoute` when no route matches its method and path, including QUERY, which Gin does not register. The original URL is unchanged. Do not use gin.WrapH alone `engine.NoRoute(gin.WrapH(app))` works for most requests, but Gin writes its own `404 page not found` body after Arc’s empty 404 because Arc wrote no body. The `WriteHeaderNow` call commits Arc’s status first. The `immediateWriter` bridge also commits status 101 before WebSocket hijacking, which otherwise leaves the handshake buffered behind Arc’s response wrappers. ## What the recipe proves The test serves the engine over real HTTP with Gin v1.12.0 and checks command execution, `/validate` without execution, GET and QUERY argument binding, HEAD, Arc’s empty 404/405, and that cancelling the client request cancels the query’s context. Direct SSE flushes two ordered results, direct WebSocket upgrades and sends two results, and disconnect and application shutdown join the source. Correlation IDs and QUERY’s `no-store` survive the host. It does not cover other Gin versions, hub multiplexing or Gin middleware you add. ## Identity and lifecycle A Gin-authenticated user is not an Arc principal. Register an Arc authentication handler, such as the [JWT bearer recipe](/arc/go/backend/go/recipes/jwt/), or install verified `identity.WithPrincipal` metadata with `authentication.HostPrincipal()`. Your host owns the listener and server timeouts. Initiate `server.Shutdown` and `app.Shutdown` together, then join both with fresh bounded cleanup budgets, as in the [coordinated shutdown helper](/arc/go/backend/go/recipes/chi/#own-the-server-lifecycle). Waiting for HTTP drain first stalls active SSE handlers until the deadline; Arc must cancel their observations while HTTP drains. The Gin tests exercise this sequence with an active SSE stream. See [hosting](/arc/go/backend/go/core/hosting/). # Authenticate JWT bearer tokens > Verify bearer tokens with golang-jwt v5 in an Arc authentication handler. Your frontend or API clients send `Authorization: Bearer ` and your commands and queries must know who is calling. Arc does not parse JWTs itself: it runs the authentication handlers you register and authorizes against the principal they return. This recipe supplies that handler with [golang-jwt v5](https://github.com/golang-jwt/jwt). ## Write the handler The handler maps a verified token onto an Arc principal. This code is compiled and tested in the [recipes module](/arc/go/backend/go/recipes/): ```go // Claims are the token claims this recipe maps onto an Arc principal. type Claims struct { jwt.RegisteredClaims Name string `json:"name,omitempty"` Roles []string `json:"roles,omitempty"` TokenUse string `json:"token_use"` } // Validate requires this issuer's access-token marker; ID tokens are not API // credentials. Adapt this check to your issuer's documented token profile. func (c Claims) Validate() error { if c.TokenUse != "access" { return errors.New("not an access token") } return nil } // Bearer returns an Arc authentication handler for "Authorization: Bearer" // tokens. Requests without that scheme stay anonymous for later handlers and // authorization; a bearer token that does not verify is a terminal 401. // Supply jwt.WithValidMethods, issuer, audience and expiry options: the // parser accepts whatever the options do not forbid. func Bearer(key jwt.Keyfunc, options ...jwt.ParserOption) authentication.Handler { parser := jwt.NewParser(options...) return authentication.HandlerFunc(func(ctx context.Context, r *http.Request) (authentication.Result, error) { if err := ctx.Err(); err != nil { return authentication.Result{}, err } headers := r.Header.Values("Authorization") if len(headers) == 0 { return authentication.Anonymous(), nil } if len(headers) != 1 { return authentication.Failed("ambiguous authorization header"), nil } parts := strings.Fields(headers[0]) if len(parts) == 0 || !strings.EqualFold(parts[0], "Bearer") { return authentication.Anonymous(), nil } if len(parts) != 2 { return authentication.Failed("invalid bearer token"), nil } claims := &Claims{} if _, err := parser.ParseWithClaims(parts[1], claims, key); err != nil { // Reasons stay local; never echo token text or parser details. if errors.Is(err, jwt.ErrTokenExpired) { return authentication.Failed("expired bearer token"), nil } return authentication.Failed("invalid bearer token"), nil } if err := ctx.Err(); err != nil { return authentication.Result{}, err } if claims.Subject == "" { return authentication.Failed("bearer token has no subject"), nil } return authentication.Authenticated(identity.NewPrincipal(identity.PrincipalData{ ID: claims.Subject, Name: claims.Name, AuthenticationType: "Bearer", Roles: claims.Roles, Claims: []identity.Claim{{Type: "iss", Value: claims.Issuer}}, })) }) } ``` `authentication.Failed` is terminal: Arc answers 401 and runs no command or query, even a public one. `authentication.Anonymous` lets the next handler try, then leaves authorization to decide. ## Register it with Arc Pin the accepted algorithm, issuer and audience, and require an expiry: ```go bearer := jwtauth.Bearer( func(*jwt.Token) (any, error) { return signingKey, nil }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(issuer), jwt.WithAudience(audience), jwt.WithExpirationRequired(), ) options := arc.Options{Authentication: []authentication.Handler{bearer}} ``` Pass `options` to `arc.NewBuilder`. Handlers then read the caller with `identity.PrincipalFrom(ctx)`, and role requirements in `metadata.Authorization` match the token’s `roles` claim. Always pin the algorithm Without `jwt.WithValidMethods`, the key function decides which algorithms are acceptable. Pin the exact algorithm your issuer uses, and for asymmetric keys return only the public key matching the token’s `kid`. ## Responses your clients see | Request | Status | | --- | --- | | Valid token with a required role | 200 | | Valid token without a required role | 403 | | Expired, unsigned (`none`), wrongly signed, disallowed algorithm, wrong issuer or audience, no expiry, no subject, malformed | 401 | | No `Authorization` header, or another scheme such as `Basic`, on a role-protected operation | 403 | | No `Authorization` header on a public operation | 200 | The issuer in this recipe marks access tokens with `token_use: "access"`. ID tokens and tokens without that marker are rejected even if their signature, issuer and audience match. Adapt `Claims.Validate` to your issuer’s documented access-token profile; `token_use` is not a universal JWT claim. Repeated Authorization headers and a bare `Bearer` scheme also return 401. Cancellation returns an error before key resolution, and key resolution failure denies the request rather than treating it as anonymous. The 403 for an anonymous caller on a role-protected operation matches C# Arc: role denial is authorization, not authentication. The response body never contains the token. ## When this is the wrong fit Use your identity provider’s middleware or an authenticating ingress if it already validates tokens and fetches signing keys; then supply the result with `identity.WithPrincipal` and `authentication.HostPrincipal()`. This recipe does not fetch or rotate JWKS keys. Supply locally available verification keys; never fetch a URL from an unverified token’s `jku` or `x5u` header. See [authentication](/arc/go/backend/go/authentication/). # Trace requests with OpenTelemetry > Wrap Arc with otelhttp so each request gets a server span and handlers join the caller's trace. You run Arc behind other services and want one trace across them: the caller’s `traceparent` should become the parent of a server span, and your command and query handlers should log and call downstream services inside that span. Arc has no OpenTelemetry dependency; wrap the application with [otelhttp](https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp). ## Wrap the application This code is compiled and tested in the [recipes module](/arc/go/backend/go/recipes/): ```go // Instrument returns app wrapped in one OpenTelemetry server span per request. // The incoming trace context becomes the parent, and Arc handlers receive the // server span through their request context. func Instrument(app *arc.Application, provider trace.TracerProvider, propagator propagation.TextMapPropagator) http.Handler { // Name spans after Arc's own endpoints. Unmapped paths fall back to the // method alone so a caller cannot create unbounded span names. routes := make(map[string]bool) for _, endpoint := range app.Endpoints() { routes[endpoint.Method+" "+endpoint.Path] = true } return otelhttp.NewHandler(app, "arc", otelhttp.WithTracerProvider(provider), otelhttp.WithPropagators(propagator), otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string { if name := r.Method + " " + r.URL.Path; routes[name] { return name } switch r.Method { case http.MethodGet, http.MethodHead, http.MethodPost, "QUERY": return r.Method } return "HTTP" }), ) } ``` Pass your SDK’s tracer provider and `propagation.TraceContext{}` (or a composite propagator). Passing them explicitly keeps the recipe independent of the global OpenTelemetry state. Inside a handler, `trace.SpanContextFromContext(ctx)` returns the server span. ## Correlate application logs Use this bridge with a nonnil, application-owned `slog.Logger` and the handler’s context. Call the returned logger’s `InfoContext` or `ErrorContext` methods; request-derived trace fields do not mutate the base logger or global logging. ```go // Logger derives a request logger from a nonnil application-owned logger. // Trace IDs are log correlation fields, never metric labels. No valid span // means the original logger is returned; neither logger nor globals mutate. func Logger(ctx context.Context, logger *slog.Logger) *slog.Logger { span := trace.SpanContextFromContext(ctx) if !span.IsValid() { return logger } return logger.With( slog.String("traceId", span.TraceID().String()), slog.String("spanId", span.SpanID().String()), ) } ``` Create your SDK provider before starting Arc. After stopping the HTTP server and joining `app.Shutdown`, shut the provider down with a fresh bounded cleanup context and check its error. The tests own their provider and follow that cleanup order; the wrapper never installs a global provider. Ingress spans do not replace [backend operation diagnostics](/arc/go/backend/go/core/diagnostics/). Do not log credentials, command payloads or sensitive query arguments, or use tenant/user IDs as metric labels. Expose metrics, pprof and administrative endpoints only on deliberately configured routes or listeners. ## Why a span name formatter otelhttp’s default names a span after the request method and the `http.Request.Pattern` of a standard-library route. Arc does its own routing, so the default would name every span after the method only, and it names QUERY requests `HTTP` because semantic conventions only know the RFC 9110 methods. The formatter names a span after the Arc endpoint, such as `QUERY /api/tasks/search`. Unknown paths keep a bounded name. The attributes still follow the semantic conventions: a QUERY span records `http.request.method` as `_OTHER` and `http.request.method_original` as `QUERY`. Query your tracing backend on the original method. ## What the recipe proves With otelhttp v0.72.0 and the OpenTelemetry SDK v1.47.0, the tests check the parent span from an incoming `traceparent`, that the query handler observes the server span, the QUERY span name and method attributes, bounded names for unmapped paths, log correlation without changing the base logger, and the full mount contract through the wrapper, including direct SSE/WebSocket and shutdown. Metrics and exporters are outside the recipe; configure them in your SDK setup. # Validate with go-playground/validator > Report go-playground/validator rules as Arc validation results through a validator callback. Arc’s portable `validate:"required"` tag covers presence, but your commands need e-mail formats, lengths and rules on nested items. You already know [go-playground/validator](https://github.com/go-playground/validator). This recipe runs it inside an Arc validator, so its failures become ordinary Arc validation results: the handler never runs, `/validate` reports the same findings, and the frontend sees members it can attach to form fields. ## Configure the rules This code is compiled and tested in the [recipes module](/arc/go/backend/go/recipes/): ```go // NewRules returns a validator that reads the `playground` struct tag and // reports Arc wire member names. Arc owns both `validate` (required and // skipConcept) and `rules` (JSON portable rule descriptors). func NewRules() *validator.Validate { rules := validator.New(validator.WithRequiredStructEnabled()) rules.SetTagName("playground") rules.RegisterTagNameFunc(func(field reflect.StructField) string { name, _, _ := strings.Cut(field.Tag.Get("json"), ",") switch name { case "-": return "" case "": return serialization.CamelCase(field.Name) } return name }) return rules } ``` Arc owns validate and rules go-playground reads `validate` by default, and so does Arc. Arc rejects an unknown rule such as `validate:"email"` when you register the command. Arc also owns `rules`, which accepts a JSON array of portable rule descriptors, not comma-separated go-playground rules. Keep `validate:"required"` and portable `rules` for Arc; use `playground:"required,email"` or `playground:"max=10"` for go-playground, including on declarations consumed by arc-gen. ## Adapt failures to Arc results ```go // Validator adapts rules to an Arc validator for T. Each failed rule becomes // an Error finding on its wire member, so Arc rejects the command or query // before its handler runs, on /validate as well as on execution. func Validator[T any](rules *validator.Validate) validation.Validator[T] { return validation.ValidatorFunc[T](func(ctx context.Context, value T) ([]validation.Result, error) { err := rules.StructCtx(ctx, value) var failures validator.ValidationErrors if !errors.As(err, &failures) { return nil, err // nil, or a programming error such as a non-struct T. } results := make([]validation.Result, 0, len(failures)) for _, failure := range failures { member := wireMember(reflect.TypeOf(value), failure.StructNamespace()) var members []string if member != "" { members = []string{member} } rule := failure.Tag() if failure.Param() != "" { rule += "=" + failure.Param() } results = append(results, validation.Result{ Severity: validation.Error, Message: fmt.Sprintf("%s does not satisfy %s.", member, rule), Members: members, ReasonDetail: &rule, }) } return results, nil }) } // wireMember maps the Go field namespace, retaining collection indexes but // omitting anonymous struct segments that Arc flattens on the wire. Unknown // or hidden fields (including memberless struct-level errors) target the model. func wireMember(t reflect.Type, namespace string) string { _, path, _ := strings.Cut(namespace, ".") var members []string for path != "" { for t.Kind() == reflect.Pointer { t = t.Elem() } if t.Kind() != reflect.Struct { return "" } end := strings.IndexAny(path, ".[") if end < 0 { end = len(path) } field, ok := t.FieldByName(path[:end]) if !ok { return "" } path, t = path[end:], field.Type name, _, _ := strings.Cut(field.Tag.Get("json"), ",") base := t for base.Kind() == reflect.Pointer { base = base.Elem() } if name == "-" || !field.IsExported() && !field.Anonymous { return "" } flatten := field.Anonymous && name == "" && base.Kind() == reflect.Struct if name == "" { name = serialization.CamelCase(field.Name) } for strings.HasPrefix(path, "[") { end = strings.IndexByte(path, ']') if end < 0 { return "" } name += path[:end+1] path = path[end+1:] for t.Kind() == reflect.Pointer { t = t.Elem() } switch t.Kind() { case reflect.Array, reflect.Slice, reflect.Map: t = t.Elem() default: return "" } } if !flatten { members = append(members, name) } path = strings.TrimPrefix(path, ".") } return strings.Join(members, ".") } ``` The callback returns an error only for a programming mistake, such as a non-struct `T`; Arc treats that as a failure of the validator, not as a finding. Messages are client-visible, so they name only the member and the rule. Untagged fields and `json:",omitempty"` use Arc’s camelCase names; untagged anonymous structs are flattened. Memberless struct-level errors have no members, so the client shows a model-level finding rather than targeting an empty field. ## Register the validator Create the rules once and share them; a `*validator.Validate` is safe for concurrent use: ```go rules := structvalidation.NewRules() err := commands.Register[OpenAccount](builder, commands.Handle(func(OpenAccount, context.Context) (string, error) { opened.Add(1) return "opened", nil }), commands.WithPath[OpenAccount](accountPath), commands.WithValidator(structvalidation.Validator[OpenAccount](rules)), ) ``` Queries take the same validator through `queries.WithValidator`, which checks arguments bound from GET query strings and QUERY bodies alike. ## What your clients see For `{"email":"not-an-email","labels":[{"value":"ok"},{"value":"x"}]}`, both the command and its `/validate` endpoint return 400 with `isValid: false` and two results: one on member `email` with `reasonDetail` `email`, and one on `labels[1].value` with `reasonDetail` `min=2`. Every result has reason `rule`. Arc’s own `validate:"required"` findings appear beside them. # HTTP contract > Look up Arc.Go methods, status overrides, wire headers and observable transport boundaries. ## Routes and methods Commands map POST and POST `/validate`. Default snapshot queries map GET, HEAD and QUERY; GET-only maps GET/HEAD, QUERY-only maps only QUERY. Disabled QUERY is absent from Allow. Unknown routes return empty 404; unsupported methods on known Arc paths return empty 405 with lexical Allow. OPTIONS is not automatic CORS. Routes are case-sensitive at runtime, with case-insensitive registration collision checks. Literal root/trailing-slash query routes never become subtrees. Repeated slashes, dot segments, backslashes and escaped aliases/separators return empty 400 rather than redirects. Canonical percent-encoded Unicode paths are accepted. No trailing slash is silently added or removed. Raw catch-all/subtree handlers may overlap Arc routes, but the Arc table wins, including method mismatches. `/.cratis/*` is reserved even when discovery is unmapped; it never falls through to raw handlers. ## Input Missing Content-Type is accepted. Explicit application/json and application/\*+json with UTF-8 charset are accepted; unsupported media/encoding returns 415 before user stages. Bounded body/query limits return 413. Empty/null/nonobject command bodies are malformed. QUERY uses its own reader contract; unknown envelope fields and unknown arguments are ignored. GET bodies are not query arguments. X-Allowed-Severity applies only to commands: one supported signed decimal int32; Error is capped at Warning, repeats/comma combinations are ignored, and command floors cannot be weakened. Default policy retains Error only. ## Publication and status Normal precedence is success 200, unauthorized 403, invalid 400, query-not-ready 202, otherwise 500. Transport overrides are credential rejection 401, reader failure 400, oversized input 413, unsupported representation 415, unavailable admission 503 and routing 404/405. Observable first-result wait expiry is 408; [hub controls](/arc/go/backend/go/queries/observable-query-hub/) have their own empty-body status contract. Cancellation does not invent a 499 status. JSON is `application/json; charset=utf-8`, encoded before commitment. Required arrays stay `[]`; legitimate scalar zero/false/empty command responses remain present. Failed commands omit response; failed queries omit data/change set. Encoding failure removes unsafe payload/state, retains denial/validation precedence and publishes a safe exception when possible. Last resort is empty 500. A failed publication never retries a command or promises rollback of external writes. Malformed command text is exactly: > The request body could not be read or is not valid for this command. Production exception text is exactly: > An internal error occurred while processing the request. See server logs for details. Stack detail is empty unless explicitly enabled. QUERY no-store applies to every matched failure, including authentication and admission. HEAD executes GET, computes status/representation length and suppresses bytes for snapshot queries. Observable HEAD checks admission without performer/source activation or waiting. Observable routes prioritize valid GET WebSocket upgrades, then SSE Accept, then current/pending/wait snapshots. Ordinary snapshot queries never become streams. See [observable HTTP](/arc/go/backend/go/queries/observable-http/) for full framing, wait controls, terminal errors and non-resumable reconnection. ## Metadata and identity X-Correlation-ID echoes a canonical nonzero UUID and matches envelope correlation. One valid supplied header wins, then trusted context, then generation. Repeats are invalid input. HTTP receipt is captured before authentication/binding and consumed by only the immediate endpoint pipeline call. Nested operations and backend operations invoked by middleware, raw handlers or providers get fresh receipts. Invalid tenant selectors return 400 with malformedRequest validation findings; ordinary QUERY reader syntax exceptions retain their redacted exception envelope. x-cratis-tenant-id selects tenant, never membership. Inherited principal/tenant are explicitly shadowed unless a trusted authentication adapter establishes identity. Catalogs and identity are unwrapped. `/.cratis/me` is fresh, anonymous 401 and provider-denied 403, with no identity-cookie authentication. Presented legacy `.cratis-identity` cookies are expired at `/`, never decoded or reissued. Deletion preserves C# RemoveCookie’s empty value, expiry one day in the past, and absence of Domain, HttpOnly, SameSite and Max-Age attributes. Go adds Secure by default; only plain-HTTP requests whose `Host` is `localhost` or a loopback address omit Secure for local development. Direct TLS (`Request.TLS`) or an HTTPS URL always sets Secure. HTTPS in `Forwarded` or `X-Forwarded-Proto` also sets Secure, including when an HTTPS ingress forwards a localhost backend Host. Forwarded headers can only tighten this cookie policy, never relax it; they are not trusted to establish transport or identity for other purposes. Configure a trusted host adapter if your own middleware needs that information. Identity/discovery use `no-store, private` and merged `Vary: Cookie`. # Tenant selection and membership > Select a tenant from explicit request inputs and enforce membership as a separate security boundary. A tenant header tells you where a caller wants to work, not whether they belong there. You select a tenant with `tenancy.NewResolver(options)`, install it with `WithTenant`, and independently enforce any configured Membership. Arc.Go provides these contracts at HTTP ingress and enforces configured membership in both pipelines; database routing remains separate. ## Choose one selector Zero Options selects header tenancy. Empty configurable names use the defaults: | Strategy | Input | Default | | --- | --- | --- | | `Header` | Request header | `x-cratis-tenant-id` | | `Query` | Query-string parameter | `tenantId` | | `Claim` | First matching authenticated principal claim | `tenant_id` | | `Subdomain` | `Request.Host`, else configured header | Requires BaseDomain | | `Fixed` | Deployment FixedID | `development` when zero | This excerpt assumes imports for `tenancy` and an operation context `ctx`: ```go resolver, err := tenancy.NewResolver(tenancy.Options{Strategy: tenancy.Fixed}) ``` Inspect `err`, then call `resolver.Resolve(ctx, request)`. Fixed works with a nil request. Claim reads only authenticated typed principal metadata and can also work without a request. An unauthenticated principal returns NotSet even when it has a tenant claim; this is stricter than C#‘s claim selector. Other selectors return NotSet for a nil request. Missing input returns NotSet; header/query selectors reject multiple values with `ErrAmbiguousSelection`. Malformed query encoding and invalid tenant text fail rather than silently selecting a partial value. Construction copies configuration and rejects invalid strategies/names with `ErrInvalidOptions`. Built-in resolvers are immutable and concurrent-safe. Custom `ResolverFunc` callbacks borrow request data and must honor cancellation. There is no development-mode override, automatic selector chain or membership catalog. ## Match subdomains safely BaseDomain must contain at least two valid ASCII DNS labels and must not be an IP literal. Case, surrounding ASCII dots and valid numeric ports are normalized. Exactly one valid label before the suffix selects the tenant; a deep subdomain, unrelated host, malformed ASCII label or IP falls back to the configured header. Ports must fit uint16, DNS labels must fit 63 bytes, and names must fit 253 bytes. Use Request.Host, never Forwarded or X-Forwarded-Host. This dependency-free surface supports ASCII and already-punycoded names only. Unicode host/base-domain input returns `ErrUnsupportedHost`; it does not silently fall back. C# normalizes IDNA; full Unicode support is deferred. ## Enforce membership independently `Require(id)` rejects only NotSet with `ErrNotSet`; named Default satisfies it. [Execution metadata](/arc/go/backend/go/core/execution-context/) describes sentinel codecs and explicit tenant context ownership. `Membership.Authorize(ctx, principal, tenant)` returns `(bool, error)`; use an ordinary value or `MembershipFunc`. Selection never invokes it. Hosting resolves the tenant once after authentication and shadows inherited tenant metadata, including NotSet. Pipelines enforce configured membership even for public operations. Invalid built-in selection returns 400; custom resolver infrastructure failure returns 500. Required-tenant/membership denial is normally 403. An authorization declaration’s AllowAnonymous does not bypass it. Do not treat correlation, a selected tenant ID, display details or an arbitrary claim as membership approval. Tenant selection is separate from Chronicle namespace isolation. See the [parity ledger](/arc/go/parity/) for the supported subset and stricter input handling. # Test commands and queries without HTTP > Exercise the real Arc pipelines with explicit test dependencies and ordinary Go assertions. Calling `Handle` directly misses authorization, validation, preparation and resource cleanup. Use `arctest` when your specification needs those behaviors without opening an HTTP server. It runs the application’s real command and snapshot query pipelines; it does not simulate successful effects. ## Configure the same application Register the command or query with a normal `arc.Builder`, replacing dependencies with explicit constructors, closures or operation resources before building. `arctest.New(t, builder)` seals and starts that builder and registers shutdown with `t.Cleanup`. It requires no container or assertion library. This complete test uses a model-bound command: ```go package greeting_test import ( "context" "testing" arc "github.com/cratis/arc.go" "github.com/cratis/arc.go/arctest" "github.com/cratis/arc.go/commands" ) type Greeting struct { Name string `json:"name" validate:"required"` } func (c Greeting) Handle(context.Context) (string, error) { return "Hello, " + c.Name, nil } func TestGreeting(t *testing.T) { builder, err := arc.NewBuilder(arc.Options{}) if err != nil { t.Fatal(err) } if err := commands.Register(builder, commands.Handle(Greeting.Handle)); err != nil { t.Fatal(err) } fixture := arctest.New(t, builder) scenario := arctest.NewCommand[Greeting, string](fixture) result, err := scenario.Execute(t.Context(), Greeting{Name: "Ada"}) if got := arctest.RequireResponse(t, result, err); got != "Hello, Ada" { t.Fatalf("response = %q", got) } } ``` Run `go test ./...`. The assertion requires both a successful result and a present response. It still accepts legitimate zero, false and empty-string responses. Pipeline errors remain available for `errors.Is` and `errors.As`; scenario execution does not turn an expected failure into a fatal test assertion. ## Validate before executing For a scenario configured as above, this excerpt checks the advisory path: ```go result, err := scenario.Validate(t.Context(), Greeting{Name: "Ada"}) arctest.RequireNoResponse(t, result, err) ``` Validation runs authorization and validation, but not `Provide`, `Handle`, transactional scope entry or effects. Validators may read state. To prove absence of mutation, query the same fixture afterward or inspect your explicit dependency. For a rejection, assert its reason rather than its message: ```go arctest.RequireValidationReason(t, result.Details().ValidationResults, validation.DependencyUnavailable) ``` This rejection excerpt assumes `validation` is imported from `github.com/cratis/arc.go/validation` and a result from the dependency-failure case. `RequireValidationErrors` refuses a list containing only `dependencyUnavailable`: a missing test dependency must not make a domain-rule specification pass. Use the explicit reason assertion when missing dependencies are what you intend to test. ## Query the same fixture Use `arctest.NewQuery[R](fixture, fullyQualifiedName)` to select a registered snapshot query. `Perform(ctx, queries.Request{})` handles parameterless queries; `queries.RequestFor(arguments, parameters)` supplies exact typed arguments. `queries.ReadGET` or `queries.ReadQUERY` creates a request that exercises conversion without a network request. `arctest.RequireData(t, result, err)` returns successful, present data for ordinary Go comparisons. A command and query scenario borrowing the same fixture share its dependencies, not its per-operation resources. The pipeline closes each operation scope before returning the result. Register interceptors, renderers and validators through the builder just as in the application. The compiled examples in `arctest/example_test.go` and task-board workflow in `ContractTests/taskboard_scenarios_test.go` exercise both pipelines together. ## Control identity, time and lifetime Use `execution.NewContext` to supply a trusted principal, tenant and correlation ID for a call. This tests authorization, **not authentication of credentials**. Configure `arc.Options.Clock` to control operation receipt time. No global clock, principal, service provider or registry is replaced by the test kit. Startup and test cleanup each have a five-second cooperative budget. Your callbacks must honor their context; the helper does not abandon a goroutine to pretend an uncooperative callback stopped. Execution uses the context you pass. `fixture.Close(ctx)` supports an earlier explicit shutdown; later execution fails with `arc.ErrStopped`, and test cleanup can safely call shutdown again. If you do not have a `testing.TB`, `arctest.NewScenario(ctx, builder)` returns `(*Scenario, error)`. You then own a bounded `Close` call. Scenario wrappers borrow the fixture and do not own separate applications. Returned model values remain borrowed, following the normal pipeline contract. ## Know the boundary Command scenarios pass typed input directly, as C# `CommandScenario` does. They do not JSON-round-trip the command or run HTTP middleware. `NewQuery` reads snapshots; `NewObservableQuery[R](scenario, name).Capture(ctx, request, options)` synchronously captures full results through real admission/interception/guards and owns cleanup join. `CaptureOptions` defaults to 16 results, 1 MiB encoded retained results and a five-second timeout. Earlier results accompany failures; reaching the count limit succeeds only if cleanup joins. No helper claims HTTP/hub transfers, generated adapters, Chronicle seeding, operation recovery or automatic extender discovery. Use explicit fixture composition instead of C# service discovery and extension policies; use returned results instead of mutable `LastResult` state. For routing, HTTP binding, safe malformed-input responses and real process shutdown, run the Go-owned task-board conformance gate: ```sh go test -count=1 -timeout=2m ./ContractTests -run '^TestTaskBoardHTTPConformance$' ``` It runs the same nine selected Arc 22.14.0 cases through `httptest` and a separate process using the real Arc host. It is not the richer HTTP, browser or streaming parity suite. The fixture’s README records the pinned source, limits and exact coverage. For observable framing, revisions, Full/Delta/Legacy transfers, terminal denial, reconnect and shutdown, run the separate real-listener harness: ```sh go test -count=1 -timeout=2m ./ContractTests/observables ``` Its independent RFC client is not a browser test. To execute the actual pinned `@cratis/arc` transport/query APIs against the hosted Arc fixture, run: ```sh GOWORK=off GOTOOLCHAIN=local node ContractTests/observables/frontend/run.mjs ``` This requires Node 26.8.1 and npm 12.0.2; it installs locked dependencies, compiles manual query classes, builds the Go host and joins it after the client tests. The observable contract README records the executed transport/mode cases and exclusions. Node WebSocket/EventSource polyfills and class hydration are executed; Guid/date hydration, browser cookies/origins and React-hook reconstruction remain unverified. Delta reconstruction uses an explicitly independent test consumer, not a claimed React hook. In-memory race/synctest cases separately prove candidate-budget release on cancellation, explicit resource-join retries without redisposal and retained channel cleanup after partial startup. # Validation findings > Invoke typed validators and apply bounded command severity allowances without weakening authorization. Use `validation.Result` for feedback callers can act on, not exception messages. The foundation supplies typed invocation and severity policy as well as result contracts. Automatic validator discovery, graph traversal and command/query pipeline integration remain deferred. ## Fields | Field | Contract | | --- | --- | | `Severity` | Numeric `Unknown` 0, `Information` 1, `Warning` 2, `Error` 3 | | `Message` | Safe client-visible feedback | | `Members` | Wire member paths, including indexed nested paths; nil emits `[]` | | `State` | Optional application data; nil and typed nil are omitted | | `Reason` | Open string category; an empty Go value emits `rule` | | `ReasonDetail` | Optional specific identity, such as a constraint name | Known reasons are `rule`, `concurrencyViolation`, `constraintViolation`, `validatorFailed`, `dependencyUnavailable` and `malformedRequest`. Unknown reasons are preserved so a newer server does not break older clients. A retained finding of **any severity** makes command/query `IsValid()` false. Apply command policy before constructing the final result; serialization never silently removes warnings or information. Queries initially retain all findings and do not consume command allowance headers. Messages and state are not automatically redacted. Do not put credentials, parser internals or other sensitive details in these fields. `Clone()` copies members and reason-detail storage, but continues to borrow application `State`. ## Invoke typed validators Implement `Validator[T]` or pass a plain `ValidatorFunc[T]` closure. This excerpt assumes imports for `validation` and `context`: ```go validator := validation.ValidatorFunc[int]( func(_ context.Context, value int) ([]validation.Result, error) { if value < 0 { return []validation.Result{{Severity: validation.Error, Message: "Must be positive."}}, nil } return nil, nil }) ``` Call `validation.Invoke(ctx, validator, value)`. Validators run synchronously, borrow input and must honor cancellation. Invoke clones findings, retaining borrowed State. `Reject(results...)` copies findings into a `Failure`; an empty rejection is nil. Wrapped Failure errors remain recognizable through `errors.As` and `errors.Is(err, ErrRejected)`. Unexpected errors, panics and unsupported callback severities become an `InvocationError` implementing Failure. Its one finding is Error with reason `validatorFailed`, no members or state, and exactly `The value could not be validated.` Cause and Panic are inspectable local diagnostics, never client-safe text. Cancellation/deadline errors propagate instead of becoming validation. Nil or typed-nil validators return `ErrInvalidValidator`; invalid severities identify `ErrInvalidSeverity`, and invocation failures identify `ErrValidatorFailed`. ## Apply command severity policy `NewPolicy(SeverityOptions{Allowed: allowance, BlockOn: floor})` copies optional severity pointers. Only values 0–3 are valid. Zero Policy retains only Error. Explicit Allowed retains strictly greater severities; inclusive BlockOn supplies the stricter threshold and always retains Unknown. Trusted Allowed=Error can allow all supported severities only when no floor prevents it. Unsupported numeric findings conservatively block. `Filter` returns copied blocking findings. `AllowedFromHeader(values)` accepts exactly one trimmed signed decimal int32 in 0–3. The untrusted Error allowance is capped to Warning; duplicates, comma lists, names, overflow and out-of-range values are ignored. An ignored header means default policy, not permission to ignore errors. This deliberately closes the native C# header parser’s unsupported-enum bypass. Filtering does not decide authorization and must not skip later permission checks. For ownership and permission use [authorization policies](/arc/go/backend/go/authorization/), not a severity-filterable finding. See [command results](/arc/go/backend/go/commands/results/) and [query results](/arc/go/backend/go/queries/results/) for final envelope flags and status precedence. [HTTP hosting](/arc/go/backend/go/core/hosting/) forwards command allowances to that same pipeline policy. # Arc.Go parity > Versioned feature-by-feature compatibility with the pinned C# Arc contract. ## Baseline v1 Arc.Go implements foundation contracts, backend pipelines and running snapshot HTTP hosting with identity, protected discovery, observable SSE/WebSocket transports and explicit graceful lifecycle. Ledger and fixture format version: **1**. Descriptor format: `metadata.Version == 1`. Work is tracked in [foundation issue 3](https://github.com/Cratis/Arc.Go/issues/3). | Profile | Reference | Coverage | | --- | --- | --- | | Current C# authority | Arc commit `7c1e78075b737df64f69fddfaae83374f75e3612` | Source-derived foundation fixtures, behavioral tests and the bounded paired snapshot HTTP checkpoint below | | Historical Kotlin comparison | C# Arc 22.14.0; Kotlin commit `23c93a3f70008f20d99c28868010b08e3123ea97` | Task-board assertions consulted; paired HTTP suite not run | | Historical TypeScript comparison | C# Arc 22.45.0; TypeScript commit `cbb421aa1fb6e5f65deec746911ee3046dfb1af6` | Full-envelope expectations consulted; paired HTTP suite not run | C# paths in the ledger are relative to `Source/DotNET/Arc.Core/` at the authority revision above. Scalar naming/converter sources additionally pin Fundamentals `d2accc4a79b6bcf2708213c97093ab5ba6c06381`. [Fixture provenance](https://github.com/Cratis/Arc.Go/blob/main/ContractTests/fixtures/v1/README.md) identifies exact files, versions and normalization. Those foundation fixtures are not live .NET captures; the separate HTTP checkpoint below executes both hosts. ## Paired snapshot HTTP checkpoint [Issue 38](https://github.com/Cratis/Arc.Go/issues/38) remains **Partial**. `ContractTests/httpconformance` compares real Go and generated C# GET/QUERY endpoints at the authority revision above, using SDK `10.0.401`, runtime `10.0.12`, seven locked projects and exact Git-derived source membership. `TestReferenceActivation` requires all four fixture performers and eight routes; `TestPairedSnapshotHTTP` executes 36 requests across 14 fixed groups. The recorded pair has **34 strict matches and two exact allowances**. Coverage includes scalar/default binding, missing/empty/null inputs, count-before-window sorting and paging, empty/out-of-range selections, malformed controls and failed performers. Status, relevant headers, correlation, full envelopes, array order and missing-versus-null values are compared. Only object order and JSON whitespace are normalized; raw exchanges and differences remain separate from allowances. Both allowances are limited to successful `plain-paging-control/page/GET` and `plain-paging-control/page/QUERY`: only `$.paging.page` (C# 1, Go 0) and `$.paging.size` (C# 2, Go 0) differ. Totals remain zero and all four rows remain in the same order. Ordinary Go lists stay unpaged; use an explicit renderer for paging rather than relying on request metadata being echoed. These allowances never apply to failures or other requests. The checkpoint corrects query failure envelopes: malformed QUERY integer input retains its actual decoder cause without adding synthetic diagnostic causes; invalid direction text reports the reference message; paging findings use `Page`/`Size` members. `TestQueryReaderParityFailureEnvelopes` separately verifies Go-side rejection before performer, renderer and business-factory activation, including denial. That native denial test is not paired authentication evidence. See the [corpus, exact allowances and run instructions](https://github.com/Cratis/Arc.Go/blob/main/ContractTests/httpconformance/README.md). Preparation and native tests reject changed or incomplete provenance; the dedicated workflow retains bounded captures and source/provenance manifests, not source caches or binaries. Commands, authentication, HEAD, CSV, renderer failures, provider databases, streams and browser clients remain outside this paired profile. It does not establish the full CI matrix or whole-product parity. ## TypeScript proxy implementation boundaries Work is tracked by [Arc.Go issue 20](https://github.com/Cratis/Arc.Go/issues/20). The frontend authority is `Source/DotNET/Tools/ProxyGenerator` and `Source/JavaScript` at `7c1e78075b737df64f69fddfaae83374f75e3612` (Arc npm/NuGet `22.48.2`, Fundamentals npm `7.22.0`). * **Approved deviation**: sorting helpers use declared sortable result (read-model) wire fields, not query arguments, per [Arc issue 2998](https://github.com/Cratis/Arc/issues/2998). Captured C# evidence must retain its argument-based helpers unchanged. * **Scope**: portable validation-rule descriptors and declared derived-wire-model support are prerequisites, not optional reductions to a Go-only frontend API. * **Portable rule foundation**: `validation.RuleDescriptor`, `ParseRules` and `NewPortable[T]` share explicit field-tag rules with the compiler without running application validators. UTF-16 length and JavaScript whitespace are used; unsupported rules, nonportable regex, unsafe numbers and dynamic severity fail explicitly. `validation/portable_test.go` characterizes the bounded foundation. Register the compiled validator explicitly for manual registrations; a `rules` tag alone does not activate runtime validation. TypeScript-enabled generated adapters register `NewPortable` for the supported command rules. The independent Go/locked-JavaScript rule corpus and production server rejection provide bounded paired evidence; query validation and other rule forms remain unsupported. * **Declared derivatives**: `serialization.RegisterDerivedTypes` supplies a concurrency-safe process-wide discriminator contract for named interface bases and assignable concrete structs/pointers. Exact repetitions are idempotent; conflicts, reserved fields, custom codecs, absent/unknown discriminators and undeclared implementations fail. `serialization/derived_test.go` covers round trips and failure atomicity. An explicit default concrete model preserves ordinary base payloads without a discriminator; unknown IDs never fall back to that default. Go embedding alone does not declare inheritance. * **Shared field selection**: reflection and compiler adapters use `internal/modelshape.Select`, keeping wire visibility and dominance in one kernel. The existing reflection corpus remains executable; compiler corpus parity is covered by `TestCompilerAndReflectAdaptersUseTheSameFieldCorpus`. * **Descriptor graph and wire analysis**: tools coordinate the whole selected catalog before resolving routes and preserve adapter-only generation. The serialization-ready graph separates client fields, responses, cardinality, defaults, roles, HTTP preference and output selection from compiler attachments. `tools/internal/artifacts/graph_test.go` covers rich shared scalars, constants, argument presence, pages, result-field sorting declarations and conservative rejection boundaries. The actual CLI now emits supported model, command, snapshot-query, and declared observable-query families together with Go adapters. Rich dictionaries, non-string keys, and unsupported import/codec mappings fail rather than becoming an unhydrated or `any` surface. Precision warnings are deterministic. * **Shared concept compiler analysis**: tools pin released Fundamentals.Go `v0.2.0` (including the classifier delivered at `24d60f07b6090f195cd07b9f1045fb540ff49fd7`) and use `concepts/conceptstypes.Underlying`, switching only on shared `ScalarKind` values. Valid concepts map to scalar constructors rather than wrappers; invalid declarations retain shared `ErrInvalidConcept`, `TypeError` and reason values. `TestConceptProjectionUsesTheSharedDeclarationCorpus` loads the pinned shared declaration corpus, including aliases, pointer layers and conservative failures. This replaces the former rejection-only seam from [Fundamentals.Go issue 15](https://github.com/Cratis/Fundamentals.Go/issues/15). Enum member names use the shared acronym-friendly `naming.CamelCase`; field selection still uses Arc’s authoritative shared kernel, not preservation of all Go field spelling. The root runtime remains on Fundamentals.Go `v0.1.0`. Emitted proxies have strict locked-TypeScript and Node runtime evidence; the generated observable hooks have the bounded mounted evidence below, not browser or complete React parity. * **Pinned reference baseline**: `ContractTests/ProxyComparison` retains historical `22.45.0` captures and fresh `22.48.2` executable captures, locked NuGet/npm dependencies, strict untouched-proxy compilation and real-runtime characterization tests. Captures remain untouched reference evidence. Separate Go-generated Models/Commands/Queries fixtures and the actual CLI Publication fixture provide emitted-proxy evidence. Mounted generated observable hooks have their separate bounded lane below; command/snapshot hooks and paired .NET execution of these generated proxies remain unverified. * **Adapter defaults**: narrow command/model name and namespace options preserve the actual composition namespace without the removed `Builder.CommandNamespace` accessor. `TestGeneratedRegistrationOptionsPreserveNamespaceDefaults` covers the supported runtime options. The independent tools consumer regression `TestNamespaceLessAdaptersUseSupportedRegistrationDefaults` covers both global and configured composition namespaces, renamed artifacts and excluded queries; existing stale-output bootstrap coverage remains executable. * **Profile agreement**: `Builder.ExpectGeneratedEndpoints` checks owned endpoints against the final complete catalog at Build, including manual artifacts and `/validate`. `generated_contract_test.go` covers copied inputs, overlap rejection, namespace/path drift, additional manual commands changing conventional routes, and terminal frozen registration. TypeScript-enabled adapters now emit these expectations and a fingerprint; the production CLI consumer accepts the agreed profile and rejects mismatched routes without a new root API. * **Published families (Partial)**: `emit_typescript_models_test.go`, `emit_typescript_commands_test.go`, and `emit_typescript_queries_test.go` cover deterministic model/scalar/concept/enum, command, snapshot, and barrel plans; `emit_typescript_observable_test.go` and `observable_publication_test.go` add declared observable emissions, hook tuples, mixed publication, and source identity rejection without any output mutation. Strict real declarations and Node fixtures exercise rich hydration, portable command rules, signatures, defaults, and complete envelopes. The actual CLI `Publication/runtime.test.mjs` executes generated adapters over loopback HTTP, including command validation/execution, GET/QUERY snapshots, and independent server rejection. Nullable-element/default-grammar regressions and the pinned regex-key rejection (`Arc#3014`) remain explicit boundaries. Enumerable command response annotations retain the pinned C# element generic; runtime arrays and pinned null-write/numeric/temporal limits are characterized, not repaired. * **Mixed publication (Go-specific)**: `output_test.go` and `production_cli_test.go` prove manifest owner/scope/hash checks, zero-write check mode, complete physical-path preflight, stale ownership, and interrupted write/rename/delete/manifest recovery. Empty files differ from absent files; no unmanifested file is automatically adopted, even with identical marked bytes. Publication is per-file atomic in a trusted single-writer workspace, not a durable cross-root transaction or ownership migration. * **Generated observables (Partial)**: tools pin pushed Arc `78ebbf8` and released Fundamentals.Go `v0.2.0`. Exact Source/CurrentSource/State/Subject declarations unwrap owning-model emissions, including value Page/ObservedCollection, without generated workers or eager activation. Independent generated Go consumers prove admission/laziness, snapshots, failures, cancellation, and join-before-disposal. Observable collection proxies require one selected direct nonnullable ID/Id serialized as `id`, or no identity at all (C# JSON fallback); competing conventional members, including hidden/embedded ones, and a serialized `id` without ID/Id fail before publication. No arbitrary identity/provider inference. `ContractTests/observables/frontend/generated.test.mjs` executes actual CLI output with default WebSocket hub/Delta, shared socket, date/model hydration, exact envelopes/callback counts, argument replacement, independent cancellation, terminal Unauthorized, and cumulative joined-source/host counters. Its final collection is an independent test consumer, not React. * **Mounted generated observables (Partial)**: `frontend/react.test.mjs` under `ContractTests/observables` executes actual generated `use`, `useChangeStream` and `useWithPaging` with Arc.React `22.48.2`, React and react-test-renderer `18.3.1`. Real providers explicitly select WebSocket hub/Delta, not the complete `` wrapper’s default SSE. Shared cache listeners reconstruct rich collections and hydrate change sets from real deltas; raw frames are asserted separately. Equal arguments do not reopen; alpha-to-beta replacement preserves the pinned cross-baseline change-stream diff. Zero-retention unmount awaits source closure and resource disposal before fallback cleanup. Default retention proves reuse followed by explicit disposal, not 30-second expiry. The separate paged mount proves tuple/request shape, reconstruction and replacement, not server-side windowing or totals. Same-key paging/sorting setter retention reproduces open [Arc issue 2869](https://github.com/Cratis/Arc/issues/2869), without compensating cache eviction or a Go server change. Joined socket/host cleanup is bounded. * **Remaining frontend gaps**: proxy-only generation, query validation beyond the bounded snapshot-query string rules (see the generated snapshot-query validation row), unsupported model/rule/default/provider forms, custom import, interface/grouping/library modes, OpenAPI publication, browser hosts beyond the bounded Chromium coverage in the Real-browser observable conformance row, broader mounted-hook families, and full paired .NET parity. Opaque MongoDB `Source[Find]` generation requires explicit emission metadata; manual runtime support does not imply generation. The Node manual lane covers a wider transport matrix than the generated default case. The separate mounted lane adds named hook/cache reconstruction cases, not browser credentials/origins, DOM/StrictMode, suspense, network-failure reconnect, or every C# rich nullable/provider layout. The Real-browser observable conformance row adds Chromium cookie ownership, DOM/StrictMode and first-back-off reconnect; other browsers, cross-origin credentials and longer outages remain unverified. The pinned null-write issue remains [Fundamentals#1148](https://github.com/Cratis/Fundamentals/issues/1148). ## Status vocabulary * **Implemented**: the named, bounded contract has executable regression evidence. * **Partial**: the stated subset works; identified gaps remain. * **Not implemented**: no working Go surface, even if the contract is understood. * **Go-specific**: deliberate construction or behavior differences, described below. ## Feature ledger | Feature | C# source | Go surface | Status | Behavioral/wire evidence and remaining scope | | --- | --- | --- | --- | --- | | Explicit artifact descriptors v1 | `Commands/ICommandHandler.cs`, `Queries/IQueryPerformer.cs` | `metadata.Catalog`, `Command`, `Query`, `TypeName` | Go-specific | `metadata/routes_test.go`; logical identities do not depend on Go package names. Handler/type-schema graphs await their owning slices; optional authorization metadata extends format 1, with keyed literals required for v0 struct compatibility | | Stable query identity | `Queries/ModelBound/ModelBoundQueryPerformer.cs` | `Query.Identity` | Implemented | `TestQueryIdentityAndPathPrecedence`; includes model name independently of route | | Literal route resolution | `Http/EndpointRouteHelper.cs`, `StringExtensions.cs`, endpoint mappers | `metadata.Resolve`, `DefaultOptions` | Implemented | `TestConventionalRoutes`, `TestNamespaceConflictsRestoreNames`; API prefix, skips, acronym/underscore casing, name fallback and GET/QUERY/POST/validate | | Explicit path precedence | `Commands/CommandRoute.cs`, `Queries/ModelBound/ModelBoundQueryPerformer.cs` | `Command.Path`, `Query.Path`, `ReadModelPath` | Implemented | `TestQueryIdentityAndPathPrecedence`, `TestDerivedCommandPathsAreValidated`; exact casing and query trailing slash; command paths must also yield a valid `/validate` path; empty method override disables model override | | Collision/configuration diagnosis | `Queries/QueryEndpointMapper.cs` | `CollisionError`, `Resolve` | Go-specific | `TestCollisionsAreDeterministicAndInspectable`, `TestInvalidMetadataFailsExplicitly`; no first-performer-wins behavior | | UUID wire value | Guid and concept converter | `concepts.UUID`, alias of `github.com/cratis/fundamentals.go/concepts.UUID` at `v0.0.0-20261002203106-9d83ef2578a5` | Partial | `ContractTests/scalars_test.go`, `concepts/scalars_test.go`, `concepts/aliases_test.go`; unchanged golden bytes, RFC order and shared-type identity/round trips; dashed input only, not every Guid.Parse spelling | | Calendar/duration output | Fundamentals DateOnly/TimeOnly converters; System.Text.Json TimeSpan | `DateOnly`, `TimeOnly`, `TimeSpan`, aliases provided by `github.com/cratis/fundamentals.go/concepts` at `v0.0.0-20261002203106-9d83ef2578a5` | Implemented | `ContractTests/scalars_test.go`, `concepts/scalars_test.go`, `concepts/aliases_test.go`; unchanged canonical strings, zero values, full signed tick range and shared-type identity/round trips | | Calendar input | Same converters | `ParseDateOnly`, `ParseTimeOnly`, `ParseTimeSpan`, forwarding to `github.com/cratis/fundamentals.go/concepts` at `v0.0.0-20261002203106-9d83ef2578a5` | Partial | Boundary and failure-atomicity tests; invariant input only, no culture-dependent parsing or DateOnly timestamp coercion | | DateTime/DateTimeOffset | `JsonSerializerOptionsConfiguration.cs` (default System.Text.Json timestamp codecs) | `time.Time` through `serialization.Marshal`, `Unmarshal` | Partial | `TestTimestampWirePolicy` pins RFC3339Nano output: UTC `Z`, up to nine fractional digits, explicit offsets. C# DateTimeOffset emits `+00:00` for UTC and at most seven digits; DateTime suffix depends on kind. Not all C# timestamp input forms are accepted; no DateTimeOffset-specific codec | | `ConceptAs` → `Concept[T]` recognition | Fundamentals `Concepts/ConceptAs.cs`, `Json/ConceptAsJsonConverter.cs` at `d2accc4`; Arc `JsonSerializerOptionsConfiguration.cs` at `7c1e780` | `concepts.Concept[T]` alias; `serialization.ValidateType`, cached shared `Underlying` metadata | Partial | `ContractTests/concepts_test.go`: UUID forwarding, string/int, pointers, slices/map values, inspectable marker-only/nested rejection before encoding/binding, metadata-only concurrent plans, encoded fields checked with `CheckJSON`. Codecs remain application-owned; bounded GET/QUERY text binding is available through typed argument models; decimal/enum concepts and generated schema/proxy recognition remain incomplete | | Model naming/nulls/numbers | `JsonSerializerOptionsConfiguration.cs` | `serialization.Marshal`, `Unmarshal` | Partial | `serialization/serialization_test.go`, `codec_regression_test.go`; acronym-preserving naming, exact case-sensitive binding, tags, null omission, numeric enums, nonfinite floats, JSON/text codecs, embedded-field promotion and standard Go omission tags. Complex dictionary keys, polymorphism and descriptor-generated codecs await later slices | | Int32 enum JSON profile | Arc `JsonSerializerOptionsConfiguration.cs` (`7c1e780`); Fundamentals `Json/EnumConverter.cs` (`14037b1`, package 7.19.6 consumed by Arc 22.48.2) | Opt-in `serialization.NewInt32Enum`, `Int32Enum.ParseJSON`, `ErrInvalidEnumValue`; generator-owned `//arc:enum parse=int32` declarations | Implemented | `serialization/enum_test.go`, `ExampleInt32Enum`; `ContractTests/EnumContract` compares 65 Int32 reads and 8 writes with captured C# output and hydrates the numeric results through TypeScript Fundamentals 7.22.0. Declared-only numeric input, case-insensitive original names, comma-separated names combined by OR even without Flags, any-Int32 decimal strings, an open output domain and scalar-null rejection are pinned parity; ASCII and case-unique declaration admission is a Go restriction. Opt-in `//arc:enum parse=int32` emits a parser declaration and failure-atomic `UnmarshalJSON`; `TestEnumGeneratedConsumerAndStableTypeScript` runs all 65 State/Access reads and 8 writes through generated parsers and ordinary Arc serialization, checks enum-only output and imported generated codec admission, and checks unchanged TS output. `TestEnumOnlyParserOptOutRemovesOwnedAdapter` pins owned cleanup; `TestEnumOnlyParserDoesNotEmitRegistrationSurface` compiles application-owned registration names without root runtime imports; `TestImportedEnumParserRejectsStaleMembersBeforePublication` refuses drifted imported parse maps when generating/checking TypeScript proxies; `TestImportedEnumParserGoOnlyCheckRequiresSelectingEnumPackage` pins Go-only checking through the enum package or `./...`, not its root-only consumer; `TestImportedEnumDoesNotAdmitHandWrittenCodec` rejects opaque imported application codecs. `TestEnumParserOpenAPICommandInputRefusesLocalAndImportedProfiles` pins parser behavior and explicit whole-document OpenAPI refusal for local/imported command inputs; directional OpenAPI schemas for this parser are not implemented. `TestEnumGeneratorRejectsInvalidDeclarationsBeforePublication` covers backing width, missing/case-colliding/non-ASCII names and conflicting hand-written codecs. The status covers only the pinned Int32 family; other backing widths, query-string binding and later Fundamentals flags behavior are unsupported. See [Int32 enums](/arc/go/backend/go/concepts/enums/) ([#32](https://github.com/Cratis/Arc.Go/issues/32)) | | Complex-key dictionary codec | Arc `JsonSerializerOptionsConfiguration.cs` (`7c1e780`), Fundamentals dictionary converters | No generated codec | Not implemented | Generator rejects `arc:codec` with an explicit unsupported-family diagnostic; arbitrary complex-key dictionaries require an application-owned wire representation. `TestEnumGeneratorRejectsInvalidDeclarationsBeforePublication` verifies no output on an unsupported codec directive. Ordinary supported string-key maps are unchanged ([#32](https://github.com/Cratis/Arc.Go/issues/32)) | | Geospatial codec | Arc `JsonSerializerOptionsConfiguration.cs` (`7c1e780`), Fundamentals geospatial converters | No generated codec | Not implemented | `arc:codec` reports this unsupported family before output; no automatic C# coordinate representation is inferred. Supply an explicit application codec and wire mapping instead ([#32](https://github.com/Cratis/Arc.Go/issues/32)) | | Type/Uri codec | Arc `JsonSerializerOptionsConfiguration.cs` (`7c1e780`), Fundamentals Type/Uri converters | No generated codec | Not implemented | `arc:codec` reports this unsupported family before output; Go reflection types and URLs do not establish the CLR wire contract. Use an explicit wire value and mapping ([#32](https://github.com/Cratis/Arc.Go/issues/32)) | | Enumerable model-to-concept codec | Arc `JsonSerializerOptionsConfiguration.cs` (`7c1e780`), Fundamentals enumerable/concept converters | No generated codec | Not implemented | `arc:codec` reports this unsupported family before output; ordinary slices and scalar concept forwarding are unchanged, not evidence for enumerable model-to-concept conversion ([#32](https://github.com/Cratis/Arc.Go/issues/32)) | | Input presence and duplicates | `ArcOptions.cs`, `JsonSerializerOptionsConfiguration.cs`, command and query request readers | `serialization.Optional[T]`, `Some`, `Null` | Go-specific | `TestExactWireNames`, `TestNullStringPolicyAndOptionalOutput`, presence and atomic-binding tests; unknown properties including case variants ignored, exact duplicate declared names rejected. Present-null Optionals emit null; plain Go strings reject null unlike C# strings. GET/QUERY binding is covered separately below | | Framework recursion safety | `JsonSerializerOptionsConfiguration.cs` (System.Text.Json traversal) | Model and envelope encoders | Go-specific | `TestFrameworkCyclesAndNestingUseSharedBudget`; one 64-level budget across validation State, command responses, query data/findings/change sets and Optional values. Application custom codecs own their recursion safety | | Command result envelope/flags | `Commands/CommandResult.cs` | `commands.Result[R]`, `Details`, `Success`, `WithResponse` | Implemented | Nine command goldens plus exhaustive status/response-presence tests; pipeline exception conversion/redaction is covered separately below | | Failed scalar response omission | `CommandResult.ClearResponse` | `commands.NewResult` | Go-specific | `TestStatusPrecedenceAndFailedResponseOmission`; removes failed zero-valued responses rather than emitting C# default(T) | | Query result envelope/flags | `Queries/QueryResult.cs` | `queries.Result[T]`, `Details`, `Success`, `NotReady` | Implemented | Eight query goldens, readiness and precedence tests; ready null is not pending | | Paging response metadata | `Queries/PagingInfo.cs` | `queries.PagingInfo` | Partial | `TestPagingInfo` and page golden cover in-range semantics; out-of-range page counts return MinInt32 deterministically, without a runtime-specific C# overflow claim. request readers, active paging validation and opt-in rendering are covered below | | Change-set envelope | `Queries/ChangeSet.cs` | `queries.ChangeSet` | Implemented | Delta golden, independent array copies and item encoding failures; diff and delivered transfer evidence have separate rows below | | Validation findings | `Validation/ValidationResult*.cs` | `validation.Result`, `Severity`, `Reason` | Implemented | Four standalone goldens plus findings in results; open reasons, nil state omission and required members array | | Standard status precedence | `Http/EndpointRouteHelper.cs` | Result `StatusCode` methods | Implemented | All constructed flag combinations; hosting tests add ingress 401, QUERY reader 400 and observable first-result wait 408 | | Command/query shared lifecycle | `Commands/CommandPipeline.cs`, `Queries/QueryPipeline.cs` | `commands.Pipeline`, `queries.Pipeline` | Implemented | `ContractTests/pipelines_test.go`, `pipeline_failures_test.go`, `model_bound_test.go`; combined evaluator, query-from-command resource reuse, security continuity, correlation, distinct receipts, lazy dependencies, cleanup suppression and unchanged existing goldens. Backend execution only; no HTTP claim | | Principal/context | `Authorization/CurrentPrincipalAccessor.cs` (`7c1e780`) | `identity.Principal`, `WithPrincipal`, `PrincipalFrom` | Go-specific | `identity/TestPrincipalSnapshot`, `TestPrincipalContexts`; immutable explicit contexts replace ambient accessors, preserving explicit anonymous shadowing; no authentication or authorization | | System actor | `Authorization/SystemPrincipal.cs` (`7c1e780`) | `identity.System` | Implemented | `identity/TestSystemRoles`; C# subject/name/role claims, supplied roles only, no privileged bypass | | Tenant context | `Tenancy/TenantIdAccessor.cs` (`7c1e780`) | `tenancy.WithTenant`, `TenantFrom` | Go-specific | `tenancy/TestTenantContexts`; nested/concurrent explicit contexts replace disposable ambient overrides; NotSet shadows parents | | Tenant sentinel semantics | `Tenancy/TenantId.cs` (`7c1e780`) | `tenancy.ID`, `ParseID`, `Default` | Partial | `tenancy/TestTenantSentinelsAndCodecs`, `FuzzTenantText`, `FuzzTenantJSON`; zero internally represents `[NotSet]`, named Default remains distinct; Go maps `""` to NotSet whereas C# keeps `""` as a distinct tenant. Go also rejects controls, invalid UTF-8 and surrounding whitespace and accepts JSON strings only | | Correlation | `Execution/CorrelationIdResolver.cs` (`7c1e780`) | `correlation.ID`, `Parse`, `Normalize`, `Resolve`; `WithID`/`FromContext` delegate to Fundamentals.Go at `66acfed` | Partial | `correlation/TestCorrelationParsingAndReplacement`, `TestCorrelationContextPrecedence`, `FuzzCorrelationParse`, `TestSharedCorrelationContext`, `TestSharedCorrelationNilContextPanics`; UUID alias, bidirectional shared context, read-only access, zero shadowing, cancellation and nil-context semantics unchanged; dashed input subset, no other Guid spellings | | Receipt metadata | `OperationContextScope.cs` (`7c1e780`) | `execution.WithReceivedAt`, `ReceivedAt`, `Capture`, `NewContext` | Go-specific | `execution/TestReceiptNormalizationAndOverrides`, `TestExplicitMetadata`; UTC value contexts replace ambient leases; pipeline receipts/correlation are covered by `ContractTests/TestCommandQuerySharedScopeCompositionEquivalence`; HTTP forwarding is covered by `TestHTTPReceiptPrecedesAuthenticationAndNestedReceiptIsFresh` | | Fundamentals DI/scopes | `HostBuilderExtensions.cs` (`7c1e780`) | Fundamentals.Go `dependencyinjection` and optional `container` at `0fd6b51`; `execution.ResourcesFrom`, `Resolve`, `ContextGuard` | Go-specific | `execution/background_test.go`, `dependencies_test.go`: lazy exact-key resolution, fresh scopes, security guards including factory-internal resolution, joined errors and synchronous cleanup. Arc’s guarded resolver passes Fundamentals `ditest.RunResolver`; no root resolve or implicit command completion. Container lifetimes/graph implementation is Fundamentals-owned, not duplicated in Arc | | Startup graph checks | `ArcApplicationBuilder.cs` (`7c1e780`) | Fundamentals.Go `dependencyinjection/container.Registry.Build` at `0fd6b51` | Go-specific | Optional declared-edge validation without activation belongs to Fundamentals (its `container` tests and `ditest.RunDeclaredGraph`). Arc does not import the container at runtime or reproduce its test suite; hidden closure captures cannot be checked | | Typed invocation/failure | `Validation/ValidatorInvoker.cs`, `IValidationFailure.cs` (`7c1e780`) | `validation.Validator[T]`, `Invoke`, `Failure`, `Reject` | Partial | `validation/TestInvokeCopiesAndPreservesRejection`, `TestInvokeRedactsAndPreservesCancellation`; synchronous typed callbacks, wrapped rejection preservation, redacted infrastructure failures and cancellation; no FluentValidation discovery, declarative rule IR or generator projection; bounded graph support is recorded below | | Command severity policy | `Commands/CommandValidationResults.cs` (`7c1e780`) | `validation.Policy`, `NewPolicy`, `Filter` | Implemented | `validation/TestSeverityMatrix`; default Error-only, strict allowances, inclusive floors, Unknown blocking with floors; unsupported Go numeric findings conservatively block | | Header allowance | `Commands/CommandEndpointMapper.cs` (`7c1e780`) | `validation.AllowedFromHeader` | Go-specific | `validation/TestAllowedHeader`, `FuzzAllowedFromHeader`; one supported signed decimal int32 only; untrusted Error capped at Warning, unsupported enum bypass closed; caller must use default policy for ignored input; HTTP forwarding is covered by `TestAllowedSeverityHeaderFiltering` | | Authentication chain | `Authentication/Authentication.cs`, `AuthenticationResult.cs` (`7c1e780`) | `authentication.Chain`, `Handler`, `HostPrincipal` | Implemented | `authentication/TestTerminalOrderAndCopies`, `TestInvalidAndCancellation`, `TestTrustedHostAndConcurrentContexts`; terminal success/failure and no rescue, copied handlers and trusted typed host identity only; authenticated result requires an authenticated principal, unlike C#‘s nonnull-only result check; `TestAuthenticationClonesRequestContextHeadersAndURL` pins caller-isolated context/headers/URL; Body is shared and handlers must not read it; no auto credential/header/cookie adapter | | Authentication ingress | `Http/AuthenticationMiddleware.cs` (`7c1e780`) | Application ingress | Go-specific | `TestTerminalCredentialsAndCorrelation`, `TestAnonymousAuthorizationDenialIs403AndCredentialFailureIs401`; explicit credential failure 401 including public operations, anonymous exhaustion proceeds to authorization, ordinary denial 403; differs from C# blanket credential requirement and anonymous failure exemption | | Declaration compilation | `Authorization/AuthorizationDeclarations.cs` (`7c1e780`) | `metadata.Authorization`, `authorization.Registry.Build` | Partial | `metadata/TestAuthorizationDescriptorRoundTripAndRoutes`, `authorization/TestBuildValidationAndFreeze`, `TestPrecedenceAndFallback`, `TestRolesAndPolicyOrdering`; all levels validated, method replacement, role OR/requirement AND and copied graph; frozen explicit fallback replaces dynamic discovery; `TestReadModelDeclarationsMustAgree` rejects conflicting declarations across queries of one read model (including nil vs explicit); named schemes rejected | | Native policies | `Authorization/ArcAuthorizationPolicyRuntime.cs` (`7c1e780`) | Direct `Registry.Register`, `PolicyFunc` | Partial | `authorization/TestGuestConjunction`, `TestPolicyFailureCancellationAndConcurrency`; guest opt-in requires policy-only conjunction, errors fail closed; borrowed concurrently-callable values/closures are primary. `RegisterPolicy[P]` now accepts lazy operation-scoped factories (see below); policy factories may call execution.Resolve with a DI-backed scope. optional exact dependency manifests are checked by pipeline Build without activation; direct authorization Build remains provider-independent | | Staged evaluation | `Authorization/AuthorizationEvaluation.cs` (`7c1e780`) | `Evaluator.Prepare`, `Prepared.Evaluate(ctx, resource)`, `Check` | Partial | `authorization/TestPreparedSecurityAndZero`, `TestRecheckAfterPolicyCallback`, `TestPolicyPanicRemainsBoundaryOwned`; principal/tenant continuity, no policy invocation at Prepare or after role denial, cancellation and fresh concurrent tenant evaluations. `TestGuestPolicyReceivesEmptyPrincipalAndRetainsCallerContinuity`, `TestGuestIdentityChangeDuringPolicyInvalidatesVerdict` pin empty anonymous policy input with original-caller continuity; `TestUnknownRuntimeTargetIsRedactedAndNotConfiguration` pins non-disclosing runtime lookup errors distinct from configuration errors. Container-neutral: `EvaluateScoped` guards operation-scope lifetime/security; borrowed DI scopes can be checked with execution.Scope.CheckOwner; pipeline enforcement is covered by the command/query rows below | | Tenant selectors/membership | `Tenancy/SubdomainTenantIdResolver.cs`, `TenantHost.cs`, Header/Query/Claim/Fixed resolvers (`7c1e780`) | `tenancy.NewResolver`, `ResolverFunc`, `Membership`, `Require` | Partial | `tenancy/TestSelectorDefaultsAndInputs`, `TestSubdomainHostMatrix`, `TestInvalidOptionsAndMembershipSeparation`, `FuzzResolveHost`; ASCII/punycode only, Unicode explicitly rejected rather than IDNA-normalized; ambiguous header/query values rejected, malformed query encoding fails, DNS/port lengths validated; optional independent membership callback is a Go-specific seam, not a C# membership catalog; `TestClaimSelectorIgnoresUnauthenticatedIdentity` requires authentication for claim tenancy, stricter than C# `ClaimTenantIdResolver.cs`, which reads a claim without checking authentication; no development override or routing | | Identity details/view | `Identity/IdentityProvider.cs`, `IdentityProviderContext.cs`, `IProvideIdentityDetails.cs` (`7c1e780`) | `identity.DetailsProvider[T]`, `ProvideDetails`, `View[T]` | Partial | `identity/TestDetailsFreshSnapshotAndNonAuthority`, `TestDetailsDenialErrorsAndCancellation`, `TestViewNilDetailsAndBoundedRecursion`; anonymous suppression, fresh callback, safe view with trusted roles, copied claims, unwrapped details and bounded wire encoding. Explicit adapter subject instead of C# sub-claim inference; display fallbacks only; no forwarded-name fallback, endpoint, schema or provider discovery/registration | | Security seam composition | Same authentication/authorization/validation/tenancy/identity sources (`7c1e780`) | Explicit foundation composition | Go-specific | `ContractTests/TestSecuritySeamsRemainIndependent`; failed credentials terminal, public declarations do not bypass membership, nonblocking warnings do not imply authorization, malicious details remain display-only; no HTTP or pipeline conformance claim | | HTTP hosting/discovery | Endpoint mappers and hosting (`7c1e780`) | Root Builder/Application | Partial | Hosting ledger below records snapshot endpoints, identity, discovery and lifecycle. Direct SSE, direct WebSocket and collection-transfer hubs are recorded below; locked Node client evidence is recorded below; browser execution is partially covered in the Real-browser observable conformance row (Chromium only); full schema generation remains unverified | | Observable sources and declaration | `Queries/ObservableQueryHandler.cs`, `ObservableQueryElementOperations.cs` (`7c1e780`) | `observable.Source`, `Stream`, `Subject`, `State`; `queries.RegisterObservable` | Go-specific | `observable` lifecycle tests and `queries/TestObservableRegistrationAndPendingCurrentSnapshots`; explicit source registration and bounded independently owned handles instead of runtime observable discovery | | Observable current/pending/wait execution | `Queries/ObservableQueryHttp.cs` (`7c1e780`) | `queries.Request.WithWait`, `ObservablePipeline`, observable HTTP snapshots | Implemented | `queries/TestObservableRegistrationAndPendingCurrentSnapshots`, suppression/wait/reader tests and root `TestObservableHTTPPendingWaitAndHEADDoNotActivateSource`, `TestObservableHTTPEnumerableMessageAndHEAD`; 202 pending, 408 bounded waits, completion diagnostics, QUERY no-store, enumerable 400 and nonactivating HEAD; streaming transport evidence is recorded separately below | | Uniform observable interception and guards | `Queries/ObservableQueryElementOperations.cs`, `QueryPipeline.cs` (`7c1e780`) | `AddEmissionGuard`, `Invocation.SetSubscriptionScope` | Go-specific | Guard isolation, denial, masking, subscriber-tenant, callback-failure and terminal-frame tests in `queries/observation_test.go`; every source candidate uses authorization and detached interception; JSON-round-trippable scope data, bounded wait and retained failed-opening cleanup are deliberate hardening. Channel factories return an owned cleanup-capable handle even with a startup error or invalid channel, rather than losing cleanup on a canceled opening context | | Hub revision operation semantics | `Queries/ObservableQuerySubscriptionStates.cs`, `ObservableQuerySubscriptionRevision.cs` (`7c1e780`) | `internal/streaming.Subscriptions`, `Revision`, `ConnectionOwner` | Implemented | `internal/streaming/subscriptions_test.go` covers equal-revision unsubscribe, delayed controls, legacy upgrade, token-owned termination, capacity, drain and lexical parsing; real SSE hub controls additionally prove duplicate/stale subscribe and equal-revision unsubscribe without reactivation | | Revision tombstone retention and owner security | Same subscription state sources (`7c1e780`) | Connection-lifetime high-water marks and verified ownership snapshots | Go-specific | Tombstones are never evicted while a connection lives; new IDs reject at capacity rather than C# two-minute/1024-entry eviction. Retired workers count until joined. Stable verified subject, authentication state, tenant presence and independent anonymous evidence replace display-name/anonymous equivalence; real-listener SSE tests prove independent per-connection HttpOnly ownership cookies, exact tenant/subject matching, same-origin controls and an explicit origin allowlist. Anonymous connections are additionally capped by actual peer IP; forwarded peer headers are ignored. Cookies and connections expire after a bounded lifetime, with closed cookies removed on later eligible hub responses | | Observable application lifetime | Hosting lifetime and observable subscription operation (`7c1e780`) | `Options.Observable`, admitted `ObservablePipeline`, `Application.Shutdown` | Go-specific | Root observable lifetime tests prove observations are canceled/joined before unary drain and user hooks, and failed admission after source activation retains timed-out cleanup for later Shutdown. Limits count opening and retired operations; callback cancellation is never treated as a join. `TestShutdownCanResumeResourceJoinWithoutRepeatingDisposal` and `TestShutdownJoinsChannelFactoryCleanupRetainedAfterFailedStartup` prove explicit resource-join and partial channel startup retries; `queries/TestFailedChannelStartupRetainsCleanupPanicUntilProducerJoins` proves unknown cleanup completion retains producer ownership, resources and capacity, and later shutdown preserves panic diagnostics without repeating disposal | | Direct observable SSE | `Queries/ClientObservableSSE.cs`, `ClientEnumerableObservableSSE.cs`; JavaScript `ServerSentEventQueryConnection.ts` (`7c1e780`) | Observable endpoint negotiation, `internal/streaming.SSEWriter` | Implemented | Real-listener direct SSE tests match independent frame bytes and prove flush visibility, pending-without-synthetic-data, HTTP/2 headers, QUERY no-store, null profiles, terminal denial and disconnect/shutdown. Bounded writes, safe source-error frames and never failing an application-owned subject harden the reference; locked Node client direct SSE snapshots, model hydration, cancellation and terminal denial run in `ContractTests/observables/frontend` | | Acknowledged connection writer and retained bytes | `Queries/ObservableQueryDemultiplexer.cs` serialized sends (`7c1e780`) | `internal/streaming.Writer`, `Budget` | Partial | Deterministic writer tests prove immutable copies, write acknowledgement, legacy fences, obsolete-job suppression, terminal retirement, byte/job overload and timed-out close retaining in-flight reservations. Connection/global byte budgets deliberately harden the reference. The SSE hub integrates one acknowledged writer with application/connection byte and job budgets, serialized JSON Ping keepalives and legacy replacement fences. Direct SSE uses its synchronous writer without a queue. `queries/TestCancellationAfterCandidateReservationRestoresBothBudgets` proves candidate release when cancellation follows reservation | | Observable SSE hub and controls | `Queries/ObservableQueryDemultiplexer.cs`, `ObservableQuerySSESubscribeRequest.cs`; JavaScript `ServerSentEventHubConnection.ts` (`7c1e780`) | Fixed SSE GET/HEAD and subscribe/unsubscribe POST routes | Implemented | Real-listener hub tests prove Connected-before-data, full QueryResult envelopes, empty control bodies, creation-time 401/Unauthorized, live denial isolated to its subscription, HTTP/2, origin/owner rejection, capacity/tombstones, POST-independent resources and truthful shutdown after failed opening cleanup. Duplicate JSON keys and malformed controls explicitly return 400. Both hubs deliver full/delta/legacy collection transfers; `ContractTests/observables/frontend` executes the pinned SSE hub client for all three modes, replacement and equal-revision unsubscribe using an explicit fixture session owner, not browser cookies | | Direct observable WebSocket | `Queries/WebSocketConnectionHandler.cs`, `ClientObservable.cs`; JavaScript direct connection (`7c1e780`) | Observable GET upgrade, `internal/websockettransport` | Implemented | Real-listener `TestDirectWebSocketDataFragmentedPingAndOrderlyCompletion` pins Data wrapping, WebSocket-before-SSE negotiation, fragmented application Ping, echoed millisecond Pong and orderly completion. Pre-upgrade denial/origin rejection and nonactivating HEAD are tested. Compression is disabled; local bounded writes acknowledge delivery, not browser receipt | | Observable WebSocket hub | `Queries/ObservableQueryDemultiplexer.cs`; JavaScript `WebSocketHubConnection.ts` (`7c1e780`) | Fixed GET/HEAD `/.cratis/queries/ws` | Implemented | Real-listener tests cover Connected, two subscriptions, legacy-to-revision upgrade, duplicate/stale controls, equal-revision unsubscribe, isolated Error/Unauthorized, Ping/Pong and joining hijacked sockets on Shutdown. Shared writer/owner/byte budgets apply. JSON controls use payload and string/null arguments; invalid/binary controls close the physical connection. Full/delta/legacy transfer and replacement baseline resets are tested on real connections; `ContractTests/observables/frontend` also executes the pinned default WebSocket/delta client, all three hub modes, same-ID replacement and equal-revision unsubscribe | | Observable collection differences | `Queries/ChangeSetComputor.cs`, `ObservedCollection.cs` (`7c1e780`) | `queries.ComputeChanges`, `ObservedCollection`, `WithCollectionIdentity` | Implemented | Collection tests pin first-added-all, last duplicate value/encounter order, null IDs, reordered/empty collections, immutable removals and JSON-set fallback. Registration compiles exported ID/Id extraction, independently of command key tags. Known hints are verified against the complete intercepted diff and fall back on incomplete hints or broken generation/version continuity; no comparison-speed optimization is claimed. The custom extractor does not make differently named JSON identity fields stock-client compatible | | Observable collection transfer | `Queries/ObservableQueryDemultiplexer.cs` (`7c1e780`) | `Observation.Run`, full/delta/legacy hub transfers | Implemented | `TestHubCollectionTransferModesOnBothRealTransports`, query transfer/observation tests and streaming baseline-budget tests: first delta full, subsequent delta without data, legacy full plus changes, full without changes, identity-less full fallback, fresh replacement baselines, masked removals and suppression continuity. Candidate state commits only after callback/write acknowledgement, never queue admission; failed delivery and cancellation discard reservations. Serialized baselines do not alias source/callback values and share connection/application limits with frames. Delivery means local write/flush, not browser acknowledgement. Known hints are fully verified rather than trusted shortcuts | | Observable known-change hints | `Queries/ObservedCollection.cs`, `ChangeSetComputor.cs` (`7c1e780`) | Value `ObservedCollection[T]` and continuity fallback | Go-specific | `queries/observed_collection_test.go` and collection tests verify suppressed-middle, generation/predecessor and malformed/incomplete hint fallback against complete intercepted snapshots. Conservative verification, not a claimed comparison-speed optimization; hints never authorize omitting detected changes | | Generated observable adapters | Model-bound observable discovery (`7c1e780`) | Separate tools module, `RegisterArtifacts` and typed `RegisterObservable` performer | Partial | `observable_analysis_test.go`, independent `testdata/observable_consumer_test.go`, and `TestProductionCLIRealClientFixtureIndependentConsumption` consume pushed Arc `78ebbf8` without workspace/replace. Exact Source/CurrentSource/\*State/\*Subject declarations and aliases normalize owning-model single/pointer/list/array/value Page/value ObservedCollection emissions. Manual/generated metadata and lazy admission, current/pending, failure, cancellation and join-before-disposal are executable. No arbitrary provider/nested-source inference, pointer wrappers, or generated lifecycle workers | | JavaScript/browser observable compatibility | JavaScript `Globals.ts`, `ObservableQueryFor.ts`, connection factory/hub connections; `Arc.React/queries/useObservableQuery.ts` (`7c1e780`) | Real Arc registration/pipelines/subjects/Serve plus locked Node client | Partial | `ContractTests/observables/frontend/run.mjs` executes @cratis/arc 22.48.2 with Fundamentals 7.22.0, Node 26.8.1/npm 12.0.2 and pinned ws/EventSource polyfills. Default one-connection WS/delta, SSE hub, Full/Delta/Legacy hub results, class hydration, both direct full transports, equal-revision unsubscribe, same-ID fresh-baseline replacement, stale captured-control rejection, nil-ready/pending perform, pending WS argument replacement, cancellation and all-four terminal denial/cleanup pass. Delta final collection uses an explicitly independent test-consumer reducer, not an Arc React hook. The separate production-generated default WS/Delta case adds exact callback counts, argument replacement, date/model hydration and cumulative source/host joins using the same fixture; strict hook compilation covers both decorator modes. The separate mounted React lane is recorded below. Real-browser cookie ownership, StrictMode and failure reconnect are recorded in the browser row below; cross-origin browsers and Guid hydration remain unverified; see `ContractTests/observables/README.md` for exact cases and gaps | | Mounted generated observable React hooks | `Arc.React/queries/useObservableQuery.ts`, `useChangeStream.ts`; JavaScript `QueryInstanceCache.ts` (`7c1e780`, npm 22.48.2) | Production-generated proxies and Go fixture, real providers, React/test-renderer 18.3.1 | Partial | `ContractTests/observables/frontend/react.test.mjs` executes non-StrictMode explicit WS hub/Delta: shared `use`/`useChangeStream` cache, initial/delta rich hydration and actual collection reconstruction, equal/changed arguments with cross-baseline diff, separate paged tuple/requests/reconstruction, zero-retention unmount joins before fallback, default-retention reuse plus explicit disposal, and joined socket/host cleanup. Plain source slices do not prove paging windows/totals. Same-key setter retention characterizes [Arc#2869](https://github.com/Cratis/Arc/issues/2869), not a fix. No complete Arc wrapper, browser/DOM/StrictMode, suspense, 30s expiry, reconnection or broader transport/hook claim | | Real-browser observable conformance | JavaScript `ServerSentEventHubConnection.ts`, `WebSocketHubConnection.ts`, `ReconnectPolicy.ts`; `Arc.React/Arc.tsx`, `queries/useObservableQuery.ts`; `Queries/ObservableQueryDemultiplexer.cs` (`7c1e780`, npm 22.48.2) | Real Arc host `clientfixture.NewBrowser`/`ServeBrowser`, production-generated `All.use` under the exported `` wrapper in headless Chromium | Partial | `ContractTests/browser/run.mjs` (locked playwright-core 1.63.0, Chromium only, React/React DOM 18.3.1 development build) runs seven cases against `ContractTests/internal/browser/host`: default `` SSE hub with one `EventSource`, an `HttpOnly`/`SameSite=Strict` path-scoped owner cookie unreadable by page script and attached by the browser, and 404 without lifecycle effects for a second profile’s controls; WebSocket hub over one socket without a cookie; `StrictMode` double mount with default and zero retention keeping one live source, with unmount joining it; reconnect after a real joined server close through the pinned `ReconnectPolicy` with a new connection, owner and fresh baseline; terminal `Unauthorized` remaining mounted and unauthorized across a real host restart and a bounded 2500ms reconnect/retry window, with no new subscribe POST or source; and `SIGTERM` joined shutdown observed by both transports. `clientfixture/TestBrowserHost*` prove the restart/join host contract; `ContractTests/browser/host.test.mjs` proves joined startup-failure cleanup and forced-termination fallback. Separate Linux `browser.yml` workflow, not a required local gate. No Firefox/WebKit, TLS `Secure` cookies, cross-origin/credentialed CORS, authenticated principals, HTTP/2 connection limits, suspense, paging hooks, direct per-query transports or `ReconnectPolicy` exhaustion ([#21](https://github.com/Cratis/Arc.Go/issues/21)) | | Chronicle watches | Chronicle read-model watches (`7c1e780`) | Future optional SDK source adapter | Not implemented | Keyed reads and Arc sources do not implement an authoritative Chronicle watch; no append-notification substitute | | MongoDB bindings and BSON materialization | `MongoDB/DefaultMongoDatabaseNameResolver.cs`, `MongoCollectionAdapter.cs`, `ConceptSerializer.cs`, `MongoDBDefaults.cs`, `DateOnlySerializer.cs`, `TimeOnlySerializer.cs`, `TimeSpanSerializer.cs` (`7c1e780`, under `Source/DotNET`) | Optional `integrations/mongodb`: `Collection`, `NewCollection`, `DatabaseName`, `NewRegistry` | Partial | Module unit/race tests and independent literal BSON fixtures cover tenant defaults/case/Unicode, borrowed client/config snapshots, private registries, UUID subtype 4/RFC bytes, legacy concepts, checked numbers, null/zero/empty and failure-atomic decoding. Go uses required JSON names with optional BSON storage overrides, explicit collection names, UTC-noon DateOnly and checked rather than rounded numeric reads. Binding/registry construction performs no provider reads or publication; snapshot execution and the mandatory application-owned release callback are covered by the adjacent Partial MongoDB snapshot-rendering row. No reflection-invoked ConceptValue, global registry mutation or Optional/decimal/polymorphic mapping; real Chronicle sink layout/release remains unproved. See [storage profile](https://github.com/Cratis/Arc.Go/blob/main/integrations/mongodb/README.md) | | MongoDB snapshot rendering | `Arc.Core/Queries/QueryableQueryRenderer.cs`, MongoDB collection operations (`7c1e780`, under `Source/DotNET`) | Optional `integrations/mongodb`: `Find`, `RendererOptions`, `NewRenderer`, `Renderer.Execute` | Partial | `renderer_test.go` records frozen authorized count/find predicates, server sort/window, bounds, failures, detached cleanup and raw release-before-interception. `snapshot_example_test.go` executes external manual namespace registration through the real Arc pipeline and pinned driver’s no-database wire mock. Primary/majority with simple collation is an explicit Go profile, not atomic count+find; retry configuration stays application-owned. Chronicle release mapping is application-owned, requires unchanged count/order/identity and unique decoded identities, and never decodes protected sink fields before release. `provider_*_integration_test.go` adds MongoDB 8.0.15 task-owned replica-set evidence through actual registered Arc renderer/HTTP: two tenants and forbidden rows, matching serialized count/find coordinates/predicates/collation, server sorting/windowing, multi-batch getMore, refetch insert/update/delete/filter entry/exit/reorder/refill, codec round trip/decode retraction, synthetic raw release, integer/string and UUID representation collisions, isolated count/find/getMore failpoints, canceled-request killCursors, empty/bounded pages, denied no-I/O ingress, borrowed-client reuse and outer resource-close failure and HTTP encoding/paging retraction against pushed root `d4fec76`. Count overflow remains bounded fake evidence, not billions of live rows. Independent no-database and Linux Go 1.27 live CI lanes; see [MongoDB guide](/arc/go/backend/go/mongodb/). Still no real Chronicle sink/compliance or sharded/multi-member/failover claim; bounded watch evidence is recorded separately below | | MongoDB watches | `MongoDB/MongoDBWatcher.cs`, `MongoCollectionExtensions.cs`, `MongoDBJoinedObserveBuilder.cs` (`7c1e780`, under `Source/DotNET`) | Optional `integrations/mongodb`: `Watcher`, `Observe[T]`, `Source[Find[T]]` and manual existing-renderer registration | Partial | `filter_guard_test.go` covers pre-encoding cycle/depth guards (including nil/self-referential recursive pointer types), bounded byte production and hook-free failure. `filter_zeroer_test.go` and `discovery_regression_test.go` reject application `bson.Zeroer` before encoding/discovery across pointer method sets, named containers, nested fields and concepts without invoking `IsZero`; required JSON/text concept codecs remain supported. `filter_raw_test.go` covers recursive raw document/array/code-with-scope validation, canonical array keys, failure-atomic malformed envelopes and a lossless valid BSON corpus with duplicate ordered document keys. `filter_inline_test.go` rejects exported BSON inline fields before nil pointers or empty typed containers can bypass static inspection, including nested inline types and the driver’s exact/legacy tag tokens. Ordinary recursive structs and BSON byte-preservation cases remain supported. All instruction paths use Go-specific 64-level/16-MiB graph, static-type and BSON bounds; filters with application encoding hooks, exported inline fields or opaque vectors fail diagnostically. `stream_context_test.go` verifies that streams retain only Open’s cancellation channel/copied deadline, not performer metadata/context methods, and release that state after continued/concurrent Next joins. Go-specific cancellation classification is fixed on first observation: at/after the copied deadline means DeadlineExceeded, otherwise Canceled. Earlier cancellation observed late and custom causes cannot be reconstructed; Next’s supplied context preserves its actual error. `find_snapshot_test.go`, `watcher_test.go`, `observe_test.go` and compiled observation example cover lossless BSON instruction detachment, bounded database-owned fanout, initial handoff ordering, tenant/client/query identity, admission/overflow, cancellation and continued joins, terminal resnapshot recovery, and fresh renderer scope views. `observation*_integration_test.go` adds MongoDB 8.0.15 task-owned ordinary Aggregate change-stream evidence through actual Arc Open/Run and the existing registered renderer: cursor-before-baseline and blocked-render/delivery barriers; every full candidate compared with authorized unary count/find/release/interception; insert/update/replace/delete/filter entry/exit/off-page/reorder/refill/totals; independent principals/tenants/models/collections and same versus distinct clients; fresh row filters and revoked membership/policy denial before render I/O; metadata projection/batch64/no UpdateLookup; local overflow, startup cancellation, continued cleanup joining and cleanup timeout; terminal CursorNotFound/injected history loss without resume aggregate; cleanup-joined same-owner fresh Open with first-full Delta baseline then unary-compared changes; drop/rename termination. Natural small-oplog expiry remains unverified, as do sharding/multi-member/failover and real Chronicle sink/compliance. The existing tagged Linux provider CI lane includes these contracts. Source-only non-wait snapshots remain pending; no C# current/replay, single/null, joins, resume tokens, transparent reset, browser recovery or generated opaque-provider proxies. Explicit reopen creates the new baseline; no root driver/API change. Module README records lifecycle and differences | | Multiple application instances | `Queries/ObservableQueryDemultiplexer.cs`, `MongoDB/MongoDBWatcher.cs` (`7c1e780`, under `Source/DotNET`) | Independent Applications, each owning its client, `mongodb.Watcher`, pipeline and hub registry; no distributed registry or broker | Partial | `integrations/mongodb/two_instance_integration_test.go` in the tagged Linux MongoDB 8.0.15 lane runs two Arc applications behind a real loopback HTTP router. Each instance holds its own per-tenant-database cursors and delivers the same database change with per-principal and per-tenant row isolation. SSE hub controls for a connection on the other instance, or from a wrong subject, tenant or anonymous owner, return 404 without opening resources. Transport loss ends that instance’s streams while the survivor continues; recovery is an explicit new connection and subscription with a full baseline, and unsubscribe/resubscribe resets the delta baseline. Shutdown joins invocation resources exactly once, issues one killCursors per observed database and leaves the borrowed client usable. Owner affinity for SSE hub controls is a deployment requirement and all limits are per instance. Not multi-process, crash, real load-balancer, browser, WebSocket-hub, failover or sharding evidence; no transparent resume. See [multiple instances](/arc/go/backend/go/mongodb/multiple-instances/) ([#27](https://github.com/Cratis/Arc.Go/issues/27)) | | Proxy generation/OpenAPI | ProxyGenerator/OpenAPI | Route descriptors, generated Go adapters and generated-contract prerequisites | Partial | The generation ledger below records Go adapters, actual CLI model/command/snapshot/observable-query publication, locked TypeScript/Node execution, and untouched C# captures; bounded mounted observable reconstruction is recorded above. Opaque provider-source/proxy-only generation, OpenAPI publication, browser and broader hook conformance remain unsupported or unverified; the internal OpenAPI renderer checkpoint is recorded in the generation ledger | | Chronicle integration | Arc Chronicle integration (`7c1e780`) | Optional `integrations/chronicle` module and `sdk` adapter | Partial | Returned events, command transactions, keyed models, aggregates, reactor command effects and append observation are covered in the Chronicle ledger below. No root Chronicle dependency; no protected decisions, watches or snapshots | | Operation resources and admission | `Authorization/AuthorizationEvaluation.cs`, `OperationContextScope.cs` (`7c1e780`) | `execution.Scope`, `OpenScope`, `BorrowScope`, `RunWithResources`, `Resolver`, `CheckOwner` | Go-specific | `execution/scope_test.go`, `resources_test.go`, `panic_test.go`, `dependencies_test.go`: owned/borrowed disposal, close-once forwarding, joined admitted resolutions, expiring non-closing resolver views, principal/tenant presence; `execution/resource_join_test.go` preserves at-most-once Close and explicitly retryable ResourcesJoiner.Join, caches ordinary failures including context errors, retains non-context Join failures and typed/panic diagnostics across resumptions, and exposes ErrScopeJoinPending only for incomplete joins; `execution/scope_startup_join_test.go` and root `TestShutdownRetainsExplicitJoinAfterPartialResourceStartup` retain pending resource-opening cleanup through PendingScopeError.Scope; every pending owned Scope.Close result exposes the same capability, including successful unary opening (`TestRunWithResourcesRetainsPendingScopeAfterSuccessfulOpening`), with automatic reclaim by observable admission and explicit caller ownership elsewhere; optional DI ContextChecker/ScopeOwner checks. Arc owns structural Resources/OpenResources; Fundamentals deliberately has no lifecycle package. Unreleased `Run` and `services` are removed; migrate to RunWithResources and optional ResourcesFrom | | Model declaration and field metadata | Model-bound command/read-model attributes and `Authorization/AuthorizationDeclarations.cs` (`7c1e780`) | `metadata.InspectModel`, `Model`, `ParseQueryTags`, descriptor additions | Go-specific | `metadata/model_test.go`, `internal/modelshape/fields_test.go`; optional blank-field declarations, explicit wire keys/identity, strict tag grammar, method selection and unchanged default routes. No runtime source-comment discovery or projections; build-time adapter generation is recorded below | | Model/concept graph validation | `Validation/ModelGraphValidator.cs`, `ValidatorInvoker.cs`, `ConceptValidator.cs` (`7c1e780`) | `validation.Registry`, `Graph`, `ModelValidator`, `RegisterScoped`, `RegisterScopedConcept`, `ValidateTags` | Partial | `validation/graph_test.go`, `registry_test.go`, `tags_test.go`; exact runtime types, pointer identity, owning collection paths without indices, concept suppression and safe failures. Model Validate methods are a Go addition. Required tags are top-level only; maps are opaque, depth is capped at 64, rich rule IR remains ARC-GO-28; optional scoped manifests are checked without activation by `Graph.CheckDependencies` | | Scoped policies and catalog compatibility | `Authorization/AuthorizationEvaluation.cs`, `AuthorizationConfigurationValidator.cs` (`7c1e780`) | `authorization.RegisterPolicy`, `Prepared.EvaluateScoped`, `Evaluator.CheckCatalog` | Partial | `authorization/scoped_test.go`; no factory during Build or before role denial, guest opt-in, explicit failure without a required scope, expiring views and copied declaration comparison. Policies remain borrowed; `ContractTests/TestBothPipelineBuildsCheckSharedDependencyManifests` verifies optional exact keys at pipeline Build. `authorization/dependencies_test.go` checks copied manifests and effective catalog subsets; `validation/dependencies_test.go` checks graph manifests. Hidden closure dependencies cannot be checked | | Model-bound command registration | `Commands/ModelBound/ModelBoundCommandHandler.cs` | `commands.Register`, `Handle`, `Void`, `Invoke` | Go-specific | `commands/TestComposableRegistration`, executable Examples and `ContractTests/model_bound_test.go`; exact method sets and direct adapters replace reflective invocation. Type-only registration supports the dependency-free void signature; arbitrary signatures need a handwritten or generated typed adapter (see the generation ledger below) | | Command ownership and metadata | `Commands/CommandHandlerProviders.cs` | Command registration and descriptors; `Registry.AddBuildCheck`, `BuildView.ContainsCommandType` | Go-specific | `commands/registration_test.go`, `build_checks_test.go`; exact pointer/value types, immutable snapshots, deterministic duplicate rejection and integration checks before freezing (including late registration and failed-build retries); pin public identities explicitly when renaming a type | | Execute/Validate and stage suppression | `Commands/CommandPipeline.cs` | `Execute`, `Validate`, scoped variants | Implemented | `commands/pipeline_test.go`, `validate_test.go`, `ContractTests/model_bound_test.go`; Validate excludes preparation, handling, consumers and completion participants; validation success is advisory | | Pipeline resources | `Commands/CommandPipeline.cs`, `Queries/QueryPipeline.cs` | `OpenResources`, `ScopeFactory`, scoped execution | Go-specific | `ContractTests/TestCommandQuerySharedScopeCompositionEquivalence`; manual, resource and guarded Fundamentals container paths emit identical envelopes. Borrowed queries never dispose command resources or enlist in command completion; expiring Go views replace service-provider overloads | | Pipeline model/concept validation | `Validation/ModelGraphValidator.cs`, `ValidatorInvoker.cs`, `Queries/QueryValidator.cs`, `QueryArgumentsModels.cs` | Shared `validation.Graph`, model methods and typed validators | Partial | `commands/validate_test.go`, `queries/pipeline_test.go`, `ContractTests/TestSharedGraphAndModelConventionsBlockBothPipelines`; bounded graph/required-tag subset; model Validate is a Go convention, not CLR discovery or full DataAnnotations | | Command stage-specific severity | `Commands/CommandValidationResults.cs` | Command policy boundaries | Implemented | `commands/validate_test.go`, `outcome_test.go`; input-stage findings use severity floors; warning-only rejection errors filtered out at input stages no longer accompany successful results (`TestFilteredWarningErrorsNeverAccompanySuccessfulResults`); returned Handle warnings remain blocking | | Provide payload/control distinction | `Commands/CommandProvideInvoker.cs`, `CommandHandlerArgumentResolver.cs` | `WithProvide`, `WithPreparation`, `Prepare` | Go-specific | `commands/preparation_test.go`; exact typed payloads replace assignability/tuple searches; no fallback to DI for a missing payload | | Protected decision-read root seam (#31) | `Chronicle/ReadModels/CommandDecisionReads.cs`, `DecisionDependencySafety.cs` (`7c1e78075b737df64f69fddfaae83374f75e3612`) | `WithProtectedDecisions`, `WithUnprotectedDecisions`, `AddDecisionProvider`, `DecisionSource`, `ReadDecision`, `VerifyDecision`, `DecisionEvidence` | Partial | `commands/decision_pipeline_test.go`, `decision_reads_test.go`, `decision_expiry_test.go`, `decision_failure_test.go` and `ExampleWithProtectedDecisions` cover callback-free profile admission, target-local shared acquisition, repeated enrollment, separate validation provenance and foreign/nil/expired/overwritten read refusal before Handle, including nil-interface evidence and overwritten cached rereads before enrollment and return. Go admits no model/custom validators or operations; only Provide payloads implementing DecisionEvidence and explicit verification are inspected. No root Chronicle dependency. Provider transaction conflicts, release/compliance and lost acknowledgements are provider responsibilities, covered for Chronicle by the protected decision-read row in the Chronicle ledger; this seam alone makes no persistence or authenticated server-proof guarantee | | Raw versus response metadata | `Commands/CommandPipeline.cs` | Return type, `ResponseKind`, explicit response contracts | Go-specific | `commands/registration_test.go`, `outcome_test.go`; raw returns and client responses are distinct; dynamic consumers may leave response metadata unknown; typed Execute checks the actual response rather than rejecting unknown metadata; unrelated typed consumers preserve known response metadata (`TestTypedUnrelatedEventHandlerPreservesKnownOrderIDResponse`) | | Additive consumers and outcomes | `Commands/CommandResponseValueHandlers.cs` | Response handlers, `Outcome`, `Respond`, `Effects`, `Values` | Partial | `commands/outcome_test.go`; bounded explicit graphs, additive consumers (exact concrete keys or implemented interface keys) and control-before-effect preflight instead of automatic tuple inspection; manual flat operations are recorded below | | Flat command operations and recovery | `Arc.Core/Commands/CommandOperationExecution.cs`, `CommandOperationBoundary.cs`, `Arc.Core.Specs/Commands/for_CommandOperationExecution` (`7c1e78075b737df64f69fddfaae83374f75e3612`) | `Operation`, `Operations`, `RegisterOperation`, `WithOperations`, compatible scope registration, backend `Recovery`/`OperationOutcomes` | Partial | `commands/operations_test.go`, `operation_execution_test.go`, `operation_boundaries_test.go`, `operation_example_test.go` cover exact declarations, copied membership, whole dependency preflight, sequential entry/reverse recovery, all five existing dispositions, cancellation/panic, sticky nesting, resource lifetime and HTTP privacy. [Operation reference](/arc/go/backend/go/commands/operations/) records manual registration, unified typed dependency bundles, explicit erased Outcome participation, no arbitrary container discovery, generated operation adapters, receipts or durable workflows. The Chronicle integration supplies the operation-classified terminal; see the Chronicle operation commit row below ([#30](https://github.com/Cratis/Arc.Go/issues/30)) | | Nested execution and completion | `Commands/CommandPipeline.cs` | Bound executor and entered execution participants | Go-specific | `commands/execution_test.go`, `concurrency_test.go`; sticky per-frame execution failures, advisory nested Validate during Execute, expiring executors and reverse completion of entered participants (`TestNestedValidateFailureIsAdvisoryDuringExecute`). No automatic rollback or commit guarantee | | Guarded integration state | `Chronicle/Commands/CommandTransaction.cs`, `CommandCausationScope.cs` (`7c1e780`, under `Source/DotNET`) | `NewStateKey`, root/frame state, `ParentCommandContext` | Go-specific | `commands/state_test.go`; only bound children share root state, frame membership is isolated, expired callbacks and security/correlation changes fail. Private typed keys replace ambient ownership; borrowed mutation capabilities must still check admission | | Terminal completion ordering | `Chronicle/Commands/TransactionalCommandScope.cs`, `Arc.Core/Commands/CommandPipeline.cs` (`7c1e780`, under `Source/DotNET`) | `DeferredCommitParticipant`, `AddDeferredCommitParticipant` | Go-specific | `commands/completion_test.go`; begins first, completes after ordinary participants independent of registration order; failures/cancellation prevent commit, validation-only execution skips activation. One provider only; concrete persistence is supplied by the optional Chronicle module | | Persistence observation | `Chronicle/Commands/TransactionalCommandScope.cs` (`7c1e780`, under `Source/DotNET`) | `CompletionReport`, `ReportCommit`, `CompletionError`, `Result.Completion` | Go-specific | `commands/completion_test.go`; committed/unknown evidence survives failure, early completion and disposal; every failed scalar/object response is removed; no HTTP fields or automatic retry. Provider-reported facts, not interception of immediate SDK appends | | Return admission before nil flattening | `Chronicle/Commands/SingleEventCommandResponseValueHandler.cs` (`7c1e780`, under `Source/DotNET`) | `ReturnAdmission`, static exact types and dynamic checks | Go-specific | `commands/return_admission_test.go`; opt-in typed-nil rejection, static no-response metadata, all-leaf preflight, additive consumers, explicit Respond preserved. No inferred event registration or slice classification | | Command read-model ownership and presence | `Arc.Core/Queries/ReadModelForCommandServiceCollectionExtensions.cs`, `Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs` (`7c1e780`, under `Source/DotNET`) | `RegisterReadModelProvider`, `ResolveReadModel`, `RequireReadModel`, `ReadModelOrNil` | Partial | `commands/read_models_test.go`, `ExampleRegisterReadModelProvider`; exact-type declared/fallback/override ownership, optional absence versus present zero, invalid keys and release errors. Duplicate owners fail instead of C# replacement ordering. Providers own coordinate-aware caching/release; the optional Chronicle module supplies typed materialization | | Errors, redaction and cancellation | `Commands/CommandResult.cs`, `Queries/QueryResult.cs`, `ExceptionDetailRedactor.cs` | Result helpers and pipeline finalizers | Implemented | Command/query pipeline tests and `ContractTests/pipeline_failures_test.go`; original local errors remain inspectable, failed response/data/change sets are suppressed, production messages are redacted | | Namespace queries and directed functions | `Queries/ModelBound/ReadModelExtensions.cs`, `ModelBoundQueryPerformer.cs` | `queries.Register`, `Function`, `Invoke` | Go-specific | Namespace/function Examples and `ContractTests/TestModelBoundPipelinesMatchExistingGoldens`; unnamed value-receiver namespace methods and explicit functions use direct composition; build-time discovery is recorded in the generation ledger below | | Model/query metadata inheritance | `Queries/ModelBound/QueryPerformerProvider.cs`, `Queries/QueryEndpointMapper.cs` | Read-model registration and query options | Go-specific | `queries/registration_test.go`; method replacement, pinned identity, logical namespaces, exact types and deterministic conflicts; no package-path identity inference | | GET/QUERY argument binding | `Queries/QueryStringQueryRequestReader.cs`, `BodyQueryRequestReader.cs`, `ModelBound/ModelBoundQueryPerformer.cs` | Typed argument models, request readers and custom bindings | Partial | `queries/binding_test.go`, `request_test.go` and three fuzz targets; explicit requiredness, bounded scalar grammar, transport omission and custom JSON-node bindings; direct empty nonstring input uses missing/default rules, and boolean text accepts only trimmed case-insensitive true/false. HTTP mapping/no-store is covered by `TestGETHEADQUERYReadersAndSnapshotNegotiation` | | Paging, sorting and ordinary lists | `Queries/Paging.cs`, `QueryableQueryRenderer.cs` | Parameters, `Page`, `SliceRenderer` | Partial | `queries/request_test.go`, `renderer_test.go`; active paging validation with `Page`/`Size` findings, safe wire-field sort columns, count-before-page and no double paging. The paired snapshot checkpoint covers bounded GET/QUERY cases; plain lists remain unpaged with zero page/size metadata, unlike C# request-metadata echo in the two exact allowances above | | Exact rendering and interception | `Queries/QueryRenderers.cs`, `ReadModelInterceptors.cs` | Typed renderers and read-model interceptors | Go-specific | `queries/renderer_test.go`, `interceptors_test.go`; exact renderer keys, ordered sequential items and explicit pointer/value interceptor types; register supported wire-field comparers instead of reflected property guessing | | Authorized snapshot execution | `Queries/QueryPipeline.cs`, `QueryFilters.cs` | `Perform`, `PerformScoped` | Implemented | `queries/pipeline_test.go`, `concurrency_test.go`, cross-lane contracts; dependency construction delayed through validation; declaration recheck before invocation; no hosting or streaming claim | | Safe failures and ready-null | `Queries/QueryResult.cs`, `QueryPipeline.cs` | Query finalizer and snapshot results | Implemented | `queries/TestNilProviderOutputSkipsRendererAndInterceptors`, `TestTypedPerformFailuresAreReadyCorrelatedAndFailed`, ready-null/panic/cancellation/cleanup tests and cross-lane contracts; successful null is ready, never pending; nil provider outputs skip rendering/interception; typed failures retain correlation and suppress data/change sets | | Shared Chronicle read-model seam | `Queries/ModelBound/ReadModelAttribute.cs` | Ordinary tagged read-model structs | Go-specific | `integrations/chronicle/examples/sharedmodel/model_test.go` proves separate offline registration, concept wire names and namespace-query HTTP serving; `internal/integration/shared_model_test.go` appends events, materializes the same model and serves Arc direct/HTTP queries across namespaces. No embedded Arc base, shared registry or Arc projection ownership | | Later integrations | Observable queries and Chronicle operations/recovery | Snapshot adapters and first-slice Chronicle integration | Partial | Generated command/query adapters and Chronicle support are recorded in their ledgers; observable transports and bounded source-only MongoDB watches are covered above; Chronicle watches, protected decisions and durable operation recovery remain unsupported | | Command/query test scenarios | Arc `Source/DotNET/Testing` CommandScenario/QueryScenario (`7c1e780`) | `arctest` package | Go-specific | `arctest` tests and examples; explicit builder configuration instead of discovery; results returned rather than stored in LastResult; `NewObservableQuery` adds synchronous full-result capture with result/byte/time limits, real admission/interception/guards and explicit cancellation/cleanup join. Tests prove producer joining, pending timeout, typed preflight and cleanup failure preservation at the capture limit. HTTP/hub/browser behavior, Chronicle seeding and operation-recovery assertions remain separate or unsupported | | Full product compatibility | All C# surfaces | None | Not implemented | Foundation tests do not certify whole-product or HTTP conformance | ## Generation ledger C# sources below use Arc `7c1e78075b737df64f69fddfaae83374f75e3612`, relative to `Source/DotNET/Arc.Core/`. The experimental [generator](/arc/go/backend/go/generation/) provides Go adapters and bounded TypeScript model/command/snapshot/observable-query proxies. Frontend sources additionally use `Source/DotNET/Tools/ProxyGenerator` and `Source/JavaScript` at the same revision. OpenAPI and Screenplay publication, opaque provider-source generation, and proxy-only output remain unsupported; their internal checkpoints are recorded below. | Feature | C# source | Go surface | Status | Evidence and intentional boundary | | --- | --- | --- | --- | --- | | Command discovery and stage adapters | `Commands/ModelBound/ModelBoundCommandHandler.cs`, `Commands/CommandProvideInvoker.cs`, `CommandHandlerArgumentResolver.cs` | `tools/cmd/arc-gen`, `RegisterArtifacts`, `ArcBindings` | Go-specific | `ContractTests/TestGeneratedAndManualAdaptersHaveEquivalentStaging`, `TestGeneratedPreparationControlsAndPointerCommands`, tools `TestBootstrapRegenerationAndExecutableConsumer`; opted-in source directives and typed adapters replace CLR discovery. Exact Provide payload and stage-specific dependencies; no service construction during generation or registration | | Read-model query discovery and metadata | `Queries/ModelBound/QueryPerformerProvider.cs`, `ModelBoundQueryPerformer.cs`, `Authorization/AuthorizationDeclarations.cs` | Generated namespace-method/function adapters and public registration options | Go-specific | `ContractTests/TestGeneratedMetadataMatchesManualMetadata`, `TestGeneratedAndManualAdaptersHaveEquivalentStaging`; command/query identity, authorization replacement, argument binding and runtime validation preserved. Unnamed value receivers or explicitly directed functions replace static CLR methods; supported snapshot shapes and exact declared observable source emissions, with independent observable consumer evidence above | | Dependency binding | `Commands/CommandHandlerArgumentResolver.cs`, `Queries/ModelBound/ModelBoundQueryPerformer.cs` | Generated exact dependency manifests, optional `ArcBindings`, `execution.Resolve` | Go-specific | `ContractTests/TestGeneratedDefaultBindingsRequireDependencyCatalog`, `TestGeneratedAndManualAdaptersHaveEquivalentStaging`; plain callbacks and Fundamentals container paths, denial/Validate-only suppression. Validator/policy constructor dependencies use the same exact keys and callbacks (see the directive row below); no standalone validator/policy-only package generation or Chronicle-specific parameter injection | | Constructor service authoring | `HostBuilderExtensions.cs`, `Commands/CommandHandlerArgumentResolver.cs`, `Queries/ModelBound/ModelBoundQueryPerformer.cs` (`7c1e780`) | Opt-in `-bindings-config`, shared Fundamentals `bindingtypes` planner, separate `RegisterServices(di.Registrar)` | Partial | Tools `service_bindings_test.go`, `service_bindings_publication_test.go`, and compiled external `testdata/servicebindings` consumers cover exact keys/directives, lazy constructors, stage suppression, borrowed interface forwarding, failed-result disposal, strict duplicates and explicit Existing lifetime attestations. Production CLI consumers resolve immutable Arc `78ebbf8` and Fundamentals.Go `v0.2.0` without workspace/replace. Symbol/import-cycle/main/internal preflight leaves all outputs unchanged on analysis failure; existing writer and manifest ownership remain authoritative. Manual closures and `ArcBindings` remain supported. Foreign-owner tests model one-owner composition, not an actual Chronicle generator integration. No implicit import discovery or operation-adapter parity; validator/policy directives are recorded separately below. See [constructor services](/arc/go/backend/go/generation/constructor-services/) ([#13](https://github.com/Cratis/Arc.Go/issues/13)) | | Validator and policy directives | `Validation/ValidatorInvoker.cs`, `Validation/ModelGraphValidator.cs`, `Authorization/ArcAuthorizationPolicyRuntime.cs` (`7c1e780`) | `//arc:validator`, `//arc:policy name=`, shared zero instances and lazy constructor factories in `RegisterArtifacts` | Go-specific | Tools `TestValidatorAndPolicyDirectivesMatchManualRegistrationInAnExternalConsumer` compiles and executes a workspace-free consumer against the tools runtime pin, comparing manual and generated validation/authorization outcomes with explicit callbacks and optional DI scopes. `TestValidatorModelsCanUseExportedAliasesInAnExternalConsumer` compares generated and manual validation for exported aliases of private model types, including generic aliases. `TestValidatorAndPolicyDirectiveDiagnostics` rejects lookalike types, wrong signatures and duplicate registrations with source positions, including aliased duplicates separated by another model. The compiled consumer’s `TestGeneratedScopedRegistrationsDeclareDependencyKeys` isolates missing validator and policy dependencies, checks their runtime error identities, and accepts both explicit callbacks. Typed model/concept validators, scoped dependency keys and anonymous-policy opt-in use the existing public registrars; directives replace CLR discovery, not FluentValidation rule translation. A validator/policy-only package is explicitly rejected; see [validator and policy generation](/arc/go/backend/go/generation/validators-and-policies/) ([#13](https://github.com/Cratis/Arc.Go/issues/13)) | | Source diagnostics and regeneration | Model-bound discovery and configuration validation | `arc-gen -check`, source-position diagnostics, owned output | Go-specific | tools `TestDiagnostics`, `TestBuildTagsAndDeletedMethods`, `TestStaleOutputCleanupIsOwnedAndScoped`, `TestFailedPackageDoesNotPublishOtherOutputs`, `TestSymlinkOutputIsNeverFollowed`; selected packages/configuration only, no init execution, atomic per-file writes but no multi-file transaction. Unsupported signatures fail explicitly; tools tags remain deferred | | TypeScript proxy golden comparison ([#20](https://github.com/Cratis/Arc.Go/issues/20)) | ProxyGenerator and JavaScript at `7c1e78075b737df64f69fddfaae83374f75e3612`, pinned C# generator/client `22.48.2`; imported `ProxyComparison/Fixtures.cs` at Arc.TypeScript `2f546a89c35d49726b78ed2bf573fff8d77b056e` | Production arc-gen CLI, matched Go source, file-by-file named-fragment comparison | Implemented | `ContractTests/ProxyComparison/comparison.test.mjs` generates and checks all nine files of the pinned fixture (models, enum, command, snapshot and observable queries, barrel) against unchanged C# captures, strictly compiles live output against the locked client packages, and rejects changed fragments, extra fields/files and missing/stale allowances. Exact file/fragment/token allowances document syntax, metadata and the approved result-field sorting correction ([Arc#2998](https://github.com/Cratis/Arc/issues/2998)); the barrel matches without allowance. Identity-less observable collections (no `ID`/`Id`, no serialized `id`) are admitted like the C# `ChangeSetComputor` JSON fallback and client JSON/position reconciliation; tools `TestIdentityLessObservableCollectionsPublishLikeCSharp`. Evidence is limited to this fixture; HTTP and rendered-hook behavior are in their own rows; see the [comparison boundary](https://github.com/Cratis/Arc.Go/blob/main/ContractTests/ProxyComparison/README.md) | | TypeScript models and command proxies | ProxyGenerator model/command templates and JavaScript Fields/Command (`7c1e780`) | CLI TypeScript profile, shared wire graph, portable generated validators | Partial | Tools fixture/renderer tests and strict locked Models/Commands fixtures cover rich constructors, concepts, numeric enums, declared derivatives, response cardinality, portable rules and exact wire names. Production CLI loopback tests execute actual generated adapters; no mounted React or browser claim | | TypeScript snapshot-query proxies | ProxyGenerator query template and JavaScript QueryFor (`7c1e780`) | Model-owned single/list/array/Page proxies, endpoint expectations, result-field sorting helpers | Partial | Tools query/analyzer regressions and strict Queries/Publication fixtures cover GET/QUERY exposure, required arguments, original-Go defaults, signatures and rich hydration. Sorting is the approved result-field allowlist deviation; no provider paging/sorting or nullable collection elements; generated string query validation has its own row below; observable output has its separate bounded row below | | TypeScript observable-query proxies | ProxyGenerator `Templates/ObservableQuery.hbs`, JavaScript ObservableQueryFor and Arc.React hooks (`7c1e780`) | Mixed-family CLI, observable sorting/hook planner and shared publication lane | Partial | Production CLI/independent consumers, strict locked Observables and generated frontend fixtures cover emission cardinality, hook tuples, inherited perform, required arguments, rich hydration, and conservative identity validation. Actual generated default WS hub/Delta executes sharing, exact changes/callbacks, argument replacement, cancellation, terminal denial and joined shutdown. The separate mounted lane executes generated `use`, `useChangeStream` and `useWithPaging` with actual rich reconstruction, argument/cache behavior and joins; see the mounted ledger for setter/paging limits. Wider manual transports are separate; no browser, all-provider-layout or full nullable-shape claim | | Shared wire-contract analysis | ProxyGenerator/OpenAPI type mapping, query readers and result contracts (`7c1e780`) | Internal contract graph v2, explicit server/codec assertions and separate TypeScript admission | Partial | Tools `wire_contract_test.go`, `contract_edges_test.go`, `wire_regressions_test.go` and independently compiled `testdata/wire-contract` witnesses cover input/output presence, numeric widths, temporal string codecs, nil omission versus codec-produced null, typed validation state and encoded-null envelope omission. Resolved endpoints remain shared with adapters; unknown custom contracts require explicit assertions. Existing TypeScript consumers remain checked. The internal OpenAPI renderer checkpoint is recorded below; no CLI publication or hosting is implied and Generate still refuses OpenAPI requests | | Mixed output ownership and recovery | ProxyGenerator publication boundary; Go-specific safety contract | Manifest, pending journal and zero-write `-check` | Go-specific | Actual CLI and publisher negative cases preserve user/recovery evidence on unsupported input, physical path collisions, intervening empty files and unmanifested identical output. Per-file atomicity/process recovery only; no durable cross-root transaction, automatic legacy adoption or concurrent-writer claim | | Generated snapshot-query validation | `Queries/QueryValidator.cs`, `Queries/Filters/FluentValidationFilter.cs` and its `and_the_member_names_are_compared_with_the_client` specification; JavaScript `Arc/queries/QueryFor.ts`, `QueryValidator.ts` (`7c1e780`) | arc-gen registers `validation.NewPortable[A]` through `queries.WithValidator` and emits matching TypeScript validators | Partial | Tools `TestQueryValidationProductionConsumer`, `TestQueryValidationRefusesUnprovedContractsWithoutMutation`, `TestQueryValidationAliasesRuntimeNameAndReservesValidator`; `ContractTests/ProxyComparison/QueryValidation` executes Arc client 22.48.2. `notNull`, `notEmpty`, `minLength`, `maxLength` and `length` on snapshot query strings reject GET/QUERY before the business method and before `fetch`, with exact member names, messages, severities and UTF-16 boundaries; Warning and Info findings also block. Empty HTTP strings count as missing, matching the built-in reader. Unproved rules, observable validation, server defaults and optional nonnullable rule-bearing strings are refused without changing existing output. The pinned client lets descriptor instance fields override transported arguments after client validation, so client validation is not a security boundary; no browser or mounted-hook claim | | Declaration diagnostics (ARC0001-0006, ARC0014, ARC0019) | `Arc.Core.CodeAnalysis/ReadModelAnalyzer.cs` (0001, 0014), `CommandAnalyzer.cs` (0002-0004), `CommandProvideAnalyzer.cs` (0005), `InjectedReadModelAnalyzer.cs` (0006), `AuthorizationAttributeAnalyzer.cs` (0019), under `Source/DotNET` at `7c1e780` | `arc-vet` declaration analyzer, directive identities and imported Go analysis facts | Go-specific | `tools/internal/artifacts/TestDeclarationDiagnostics` checks exact source positions/spans for every rule, positive/negative, build-tag, alias, generated and lookalike fixtures; `tools/cmd/arc-vet/TestArcVetCommandContracts` checks driver output, imported command/read-model aliases and admission errors alongside findings. Exact payload identity replaces C# assignability; implicit owning-model snapshot shapes diagnose missing/incorrect error results, while unrelated implicit helpers stay outside discovery. Go 1.27 generic methods have version-gated coverage. ARC0002 requires package-level artifact opt-in and exempts same-package typed manual registrations (including aliases/inference), response-value handlers and other selected artifact kinds; negative CLI fixtures cover the supported manual command path. `TestDeclarationDiagnosticsManualRegistrationLimits` and CLI fixtures cover cross-package and generic-wrapper registrations: these still warn unless the command type has `arc:ignore`, the documented suppression. Pointer read models still require an absence-aware resolver. No arbitrary registration callback, blank-field metadata or Chronicle projection inference. [Rule meanings and pinned analyzer mapping](/arc/go/backend/go/generation/arc-vet/) ([#34](https://github.com/Cratis/Arc.Go/issues/34)) | | ARC0007 command record preference | `Arc.Core.CodeAnalysis/ModelBoundRecordAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | Commands use structs; Go has no class-versus-record declaration choice. See [language differences](/arc/go/backend/go/generation/arc-vet/#c-rules-that-do-not-apply-to-go) | | ARC0008 read-model record preference | `Arc.Core.CodeAnalysis/ModelBoundRecordAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | Read models use structs; no CLR record equality/immutability declaration | | ARC0009 concept record preference | `Arc.Core.CodeAnalysis/ConceptRecordAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | Named Go concepts and codecs do not inherit `ConceptAs` and have no class-versus-record choice | | ARC0010 synchronous Task wrapping | `Arc.Core.CodeAnalysis/CommandHandleTaskWrappingAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | Handlers return synchronous values/errors; Go has no Task/async/await return wrapping | | ARC0011 role nameof preference | `Arc.Core.CodeAnalysis/RolesLiteralAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | No nameof or attribute arguments; role directives use explicit strings rather than compiler-linked enum-member names | | ARC0012 built-in exception preference | `Arc.Core.CodeAnalysis/ArcArtifactBuiltInExceptionAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | Returned Go errors are not a CLR built-in exception hierarchy; domain error design and panic safety remain caller responsibilities | | ARC0013 FluentValidation concept dereference | `Arc.Core.CodeAnalysis/ValidatorConceptDereferenceAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | Go validation has no FluentValidation expression-tree selectors over nullable ConceptAs references; ordinary pointer safety is still required | | ARC0020 unenforced ASP.NET attributes | `Arc.Core.CodeAnalysis/AuthorizationAttributeAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | No ASP.NET attribute family or host integration assembly exists in Go | | ARC0021 ASP.NET authentication schemes | `Arc.Core.CodeAnalysis/AuthorizationAttributeAnalyzer.cs` (`7c1e780`) | Not applicable to Go | Go-specific | No ASP.NET AuthenticationSchemes attribute setting exists; unsupported generator directive options fail admission | | Authoring diagnostics (ARC0015) | `Arc.Core.CodeAnalysis/QueryParameterConceptTypeAnalyzer.cs` (`7c1e780`, under `Source/DotNET`) | `tools/cmd/arc-vet`, sharing arc-gen declaration admission and the Fundamentals concept classifier | Partial | Tools `TestAuthoringDiagnostics`, `TestAuthoringDiagnosticsRespectBuildTags` and `cmd/arc-vet` `TestArcVetCommandContracts`: reports a direct conversion of an exported top-level primitive argument field to a concept inside admitted `arc:readmodel`/`arc:query` bodies, including aliases and build-tag selection, skipping generated files, with text exit status 3 and standard `-json` reports. A syntax check, not dataflow: no local copies, closures, pointers, nested or promoted fields, fixes or suppressions. Declaration diagnostics and non-applicable C# rules are recorded separately above; operation and decision analyzers remain outside this scope. See the [arc-vet README](https://github.com/Cratis/Arc.Go/blob/develop/tools/cmd/arc-vet/README.md) ([#34](https://github.com/Cratis/Arc.Go/issues/34)) | | OpenAPI 3.1 document | `Source/DotNET/OpenApi` transformers, for example `OpenApi/ModelBound/CommandOperationTransformer.cs` (`7c1e780`) | `arc-gen -openapi-out` and profile `openapi.out`: a deterministic OpenAPI 3.1.1 `.json` file rendered from the finalized v2 wire graph, owned through the arc-gen manifest and verified by `-check` | Partial | tools `output_artifacts_test.go` covers CLI publication, `-check` drift and hand-edit refusal, stale-file removal, manifest ownership with and without TypeScript, identical bytes for reordered packages and whole-document refusal without writing; `openapi_document_test.go`, `openapi_codec_test.go`, `openapi_codec_refusal_test.go`, `openapi_query_test.go`, `openapi_query_boundary_test.go` and `openapi_query_arguments_test.go` cover exact graph projection, injective component names, exact integer bounds, query arguments as GET parameters and paging/sorting parameters, and QUERY carried in an `x-cratis-query` extension with a JSON body schema (`arguments`, `paging`, `sorting`) rather than aliased to GET or POST. Opt-in offline `//go:build ignore` proofs (`openapi_arguments_consumer_test.go` and siblings) load the document with kin-openapi v0.149.0 through an alternate manifest not admitted to the tools module and validate GET/HEAD requests while QUERY stays unrouted. C# evidence is the existing pinned HTTP response captures for the envelopes plus source-derived witnesses of `CommandOperationTransformer.cs` and `QueryOperationTransformer.cs` in `openapi_document_test.go`; the document is not a byte port of the C# OpenAPI 3.0 route catalog. Refuses observable queries and streams, authorization and identity customizations, validation rules, opaque/UUID/date/float codecs, derived models and model/map/presence/validated arguments. Deliberately not implemented: HTTP exposure (`MapOpenApi()`), an embedded explorer and security schemes; the document is a file only ([#24](https://github.com/Cratis/Arc.Go/issues/24)) | | Screenplay metadata export | `Source/DotNET/Screenplay/IScreenplayGenerator.cs`, `Emission/ApplicationSyntaxBuilder.cs`, `Emission/Queries/QuerySyntaxBuilder.cs`, `Emission/Types/ScreenplayPrimitiveTypes.cs` (`7c1e780`); Cratis.Screenplay 4.48.1 grammar (`e5b5698`) | `arc-gen -screenplay-out` and profile `screenplay.out` (formatVersion 2): a partial `.play` file from the shared Graph, owned through the arc-gen manifest and verified by `-check` | Partial | tools `output_artifacts_test.go` covers CLI publication, `-check` drift, stale-file removal, manifest ownership and identical bytes for reordered packages; `screenplay_test.go` and `screenplay_grouping_test.go` byte-compare `testdata/screenplay/metadata.play`, preserve input, group interleaved namespaces deterministically and refuse ambiguous or unrepresentable shapes without document bytes. `testdata/screenplay/contract.md` records compilation by the pinned Screenplay compiler fixture. Descriptive metadata only (commands, read models, snapshot and observable queries; String, Bool, Uuid, Int, Decimal and DateTime), with SPG diagnostics stating that events, projections, reactions, validation and authorization are not modeled. Deliberately not implemented: the embedded event-model viewer and any HTTP route; the export is a file only ([#37](https://github.com/Cratis/Arc.Go/issues/37)) | ## Chronicle integration ledger The C# source below is `Source/DotNET/Chronicle` at Arc `7c1e78075b737df64f69fddfaae83374f75e3612`, unless an Arc.Core path is stated. Go evidence lives under `integrations/chronicle`; kernel tests use `cratis/chronicle:19.29.4-development`. The adapter pins Chronicle.Go `9e0c8d5ba5f1` and root Arc.Go `3e2a69270f46`, without workspace replacements. This deliberate SDK baseline includes captured-client preparation and fail-closed profile corrections; it does not establish classified decision, watch, replay or general compliance support. | Feature | C# source | Go surface | Status | Evidence and remaining boundary | | --- | --- | --- | --- | --- | | Optional integration and core independence | `ArcBuilderExtensions.cs` | `Integration.Install`, `sdk.New`, nested module | Go-specific | `scripts/check-boundaries.py`, root no-container gate; SDK imports confined to adapter/examples/tests, root manifests unchanged. Nested tags use `integrations/chronicle/vX.Y.Z`; publication deferred | | Shared-provider captured client composition | `ArcBuilderExtensions.cs`, Chronicle registered-first generic-host resolution specs (`7c1e780`) | Public SDK Capture/PrepareClient, explicit `BindValue`, borrowed `sdk.New`, Arc `ScopeFactory`/`DependencyCatalog` | Go-specific | `sdk/composition_test.go`, executable `composition_example_test.go` and kernel `internal/integration/composition_test.go` verify exact shared client/collaborator identity, no setup transport or early factory activation, unprepared probes without poisoning preparation, callback/cleanup Close safety, joined preparation cleanup and borrowed-client survival through an actual transport probe. The kernel witness retains the configured resolver, excludes inherited origins, records confirmed immediate persistence despite handler failure, and joins command resources before provider disposal. Configure the resolver before Capture; prepare does not connect/register/attach/catch up. `OwnClient=false` and explicit client/provider shutdown remain application-owned; ordinary constructor/closure wiring stays supported. No operation-terminal compatibility or broader SDK safety claim | | Semantic source selection and response override | `Commands/EventSourceExtensions.cs`, `CommandContextExtensions.cs` | `EventSourceID`, provider/value interfaces, `arc:"key"`, response selection | Implemented | `integration_test.go` covers named identity versus raw UUID fields, nil declaration, provider panic, wrapper targets and response retargeting. Explicit key tags replace CLR attributes; no Id-by-name convention | | Returned events and ordered cross-source batches | `Commands/SingleEventCommandResponseValueHandler.cs`, `EventsForEventSourceIdCommandResponseValueHandler.cs` | Bare registered events, `Events`, `EventForSource`, `EventsWithScopes` | Go-specific | Unit ordering/preflight tests, SDK immutable snapshot test, kernel command tests; explicit collections rather than covariance, A1/B1/A2 order preserved. `sdk/adapter_test.go` verifies returned-event decoding preserves frozen SDK naming/codecs and propagates malformed-content failures. Rich Go wrappers can override route/subject/causation | | Command-owned transaction and failed responses | `Commands/TransactionalCommandScope.cs`, `CommandTransaction.cs` | Terminal participant, private root state, SDK UnitOfWork/Owner | Implemented | `integration_test.go`, `sdk/transactions_test.go`, kernel `commands_test.go`; nested root ownership, atomic rejection, input validation, lost acknowledgement/no retry, scalar response retraction. No distributed transaction | | Persistence disposition and immediate append observation | `Commands/TransactionalCommandScope.cs` | Completion reports, `sdk.ResolveAppendOrigin` and SDK `Sequence.OnAppend` subscription | Partial | Kernel `immediate_test.go` proves ignored rejection rolls back deferred work and confirmed immediate persistence survives later failure. Unit and kernel `shared_correlation_test.go` keep rejected/committed owner outcomes separate in both completion orders. `sdk/append_observation_test.go`, `origin_execution_test.go` and kernel `internal/integration/origin_test.go` cover exact nonzero typed-origin attribution, independent roots with the same correlation, nested inheritance, other coordinates, denied/validation masking and zero-origin refusal. Configure the SDK client with `chronicle.WithAppendOriginResolver(sdk.ResolveAppendOrigin)` before `sdk.New`; the adapter cannot retrofit an existing client. Correlation never determines ownership, and owner-unit commits use distinct SDK origins. Unsubscribe prevents new callbacks and joins admitted callbacks. Always check direct SDK results, including pre-dispatch errors, other sequences and low-level handles; this is not general interception of every external persistence path | | Constraint/concurrency mapping | `Commands/ConstraintViolationExtensions.cs`, `ConcurrencyViolationExtensions.cs` | `CommitResult.Failure`, `CommitError` | Implemented | `validation_test.go` plus kernel unique/concurrency rejection/readback; mixed diagnostics retained, explicit JSON member names improve on C# casing-only fallback, BeforeFirst is described as no events | | Per-target configured concurrency | `Commands/ConcurrencyScopeBuilder.cs` | `ConfigureCommand` flags and `ScopeResolver` | Implemented | `concurrency_test.go`; independent actual A/B targets and frame caching; `configuration_test.go` rejects unregistered and pointer/value-mismatched configuration at Build without activation, including Install before registration. SDK calls public `ResolveScope` from Chronicle.Go#43. Opaque resolved expectations are retained as provider tokens rather than guessed or reread | | Keyed model injection and ownership | `ReadModels/ReadModelServiceCollectionExtensions.cs`, Arc.Core `Queries/ReadModelForCommandServiceCollectionExtensions.cs` | Typed `sdk.BindReadModel`, core Require/optional resolvers | Partial | `models_test.go`, kernel active/passive projection and namespace tests; typed SDK reads retain absence/progress, successful frame caching, failures never cached. Explicit bindings remain necessary because Go has no runtime generic registration; current generator needs handwritten injection adapters | | Read-model release/compliance | `ReadModels/ReadModelServiceCollectionExtensions.cs` | Owning SDK Reader.Get and release contract | Partial | Unit unreleased-value refusal and kernel model reads; no duplicate release of kernel-owned reads. Full encrypted-value/compliance authoring is SDK capability-limited; model progress is not protected-decision evidence | | Aggregate history, typed mutation and loaded checks | `Aggregates/AggregateRootFactory.cs`, `AggregateRootMutation.cs`, `AggregateRootEventHandlers.cs` | `DefineAggregate`, `OnAggregateEvent`, `AggregateRoot` | Partial | `aggregate_test.go`, kernel competing decisions with rejected-target absence; no-match versus position zero, fold ordering, callback expiry, ignored failures poison transaction. `TestIncompatibleAggregateRoutesOnSameSourceFailBeforeCommit` and kernel `TestIncompatibleAggregateRoutesOnSameSourceRejectWithoutPersistence` cover the source-label restriction: differing aggregate types/routes on one source fail closed. Chronicle C# 19.29.1 `Transactions/UnitOfWork.cs` rejects conflicting scopes in strict ordered mode but overwrites them in legacy aggregate mutation; Go never drops a loaded check. The pinned SDK binds labels to source IDs, so source+route labels require an upstream contract change. Buffered history and no snapshots; non-pointer event-struct callbacks replace reflective methods. `sdk/history_test.go` proves exact registered ID/generation decoding through frozen SDK naming/codecs, refusal of unknown/mismatched/malformed content and zero aggregate folds after any history decode failure; no alternate-generation substitution or automatic local migration | | Early aggregate commitment | `Aggregates/AggregateRoot.cs` | Shared-owner `AggregateRoot.Commit` | Implemented | `aggregate_early_test.go`, `TestAggregateEarlyCommitCannotCreateSuccessor`, `TestEarlyAggregateCommitCannotIgnoreNestedLookupFailure`, kernel `TestGuardedEarlyAggregateCommitAgainstKernel`; root guard rejects ignored denied/validation/lookup and ancestor failures before persistence, successful nested work shares one owner, nested Validate remains advisory, no successor after commit, later failures retract responses but retain committed reports | | Reactor command execution and returned effects | `Reactors/CommandSideEffectExecutor.cs`, `CommandResultHandler.cs`, `CommandsResultHandler.cs` | `ReactorCommands`, `sdk.CommandEffects`, `DeliveryFrom` | Implemented | Bridge unit tests and kernel `reactors_test.go`; real Arc commands before acknowledgement, stop-on-failure, earlier commits survive, failed observer partition, per-event metadata. SDK keeps per-batch reactor activation; each command owns a fresh Arc operation. Kernel `reactor_redelivery_test.go` keeps the delivery unacknowledged while commands run, commits earlier commands before a later one completes, records a failed partition at the original position after rejection, runs no later command before recovery, and joins reactor and command resources at shutdown ([#14](https://github.com/Cratis/Arc.Go/issues/14)). No deduplication guarantee | | Identity, tenant mapping and bounded causation | `Commands/CommandCausation.cs`, `CommandCausationValues.cs`, `Tenancy/TenantNamespaceResolver.cs` | Trusted actor mapper, exact namespace resolver, explicit Audit policy | Go-specific | Anonymous-actor tests, kernel tenant/reactor tests, asymmetric GUID SDK fixture. Unknown nested/PII properties are not reflected into audit data; explicit approved values only, bounded per-value/total metadata. Event actor metadata never grants roles | | Frozen selected-store catalogs | `ArcBuilderExtensions.cs` and Chronicle client catalogs | SDK `Client.Catalogs` | Implemented | SDK adapter uses Chronicle.Go#42 without connection at construction; typed bindings still verify producer ownership | | Chronicle operation commit and compensation | `Chronicle/Commands/TransactionalCommandScope.cs`, Arc `Commands/CommandOperationExecution.cs`, `CommandOperationBoundary.cs` (`7c1e780`, under `Source/DotNET`) | Installed integration’s sole `AddOperationCommitParticipant` terminal, read-only `ObserveCommit`, operation-frame `AggregateRoot.Commit` refusal | Partial | `operations_test.go` and executable `operation_example_test.go` cover observation recorded by the authorized filter and opened by the terminal Begin before Provide/Handle/operations, validation failure and validation-only without observation or Begin, every completion driving recovery, pre-entry Committed/Unknown/Mixed refusal, fail-closed `ErrUnsupported` without an `AppendObserver`, empty operation commands and explicit-commit refusal with unchanged ordinary commands. Kernel `internal/integration/operations_test.go` runs against `cratis/chronicle:19.29.4-development`: no persisted work, constraint-rejected NotCommitted, confirmed immediate Committed, Unknown through an injected lost append acknowledgement, handler-time confirmed/unknown immediate appends refusing operation entry with persisted readback, Mixed, committed deferred work, retained `*chronicle.EventStore` compensating writes after the terminal step, fresh-origin attempts, tenant isolation and refused explicit commits that persist nothing. `scripts/check-chronicle-snippet.py` gates the operation excerpt against its compiled example. Missing `sdk.ResolveAppendOrigin` on a client cannot be detected and is a documented precondition; direct application appends by filters/validators before terminal Begin are unattributed and outside observation (unit regression). A rejected handler append can still admit operations before rollback/compensation. No generated operation adapters, nested workflows, durable recovery or retries. See [Chronicle guide](/arc/go/backend/go/chronicle/#compensate-operations-against-chronicle-outcomes) ([#30](https://github.com/Cratis/Arc.Go/issues/30)) | | Protected decision reads (#31) | `ReadModels/CommandDecisionReads.cs`, `DecisionDependencySafety.cs` | `sdk.EnableDecisions`, `sdk.ReadDecision`, `sdk.Decision`, `Integration.EnrollDecision`, `DecisionParticipant` | Implemented | Kernel `internal/integration/decisions_test.go` against `cratis/chronicle:19.29.4-development`: an event-log fold sees a just-committed booking and refuses the stale decision; a competing append after enrollment rejects a two-source batch as `NotCommitted` with a concurrency violation and nothing of the batch persisted; a lost batch acknowledgement stays `OutcomeUnknown` with persisted readback and no retry; a validation-only filter read folds without beginning or enrolling and is never the identity execution reuses, while execution shares one read between filter and Provide; classified (`WithPII`) models fail with `DecisionReadRefused`/`protected-model`, unregistered models with both `ErrNotRegistered` and `ErrDecisionRead`, unmarked commands with `ErrDecisionProfile`, and unprotected commands receive an unenrolled advisory snapshot. `sdk/decisions_test.go` covers invalid arguments, unregistered models and frozen-routing failures preserving their cause plus `ErrDecisionRead` only in active protected invocations, without reaching Handle or acquisition; store mismatches retain both identities through root admission. Kernel `TestUnrelatedAppendDoesNotRejectTheDecisionBatchAgainstTheKernel` proves conflict guards cover dependency event types for the read key, not unrelated events on the same source. `decisions_test.go` covers one lazily begun transaction under serialized concurrent enrollment, refusal and unsupported participants poisoning ignored work into rollback, and validation-only refusal before Begin; `sdk/decision_reads_test.go` covers foreign evidence, typed Check, classified/unsupported refusal and participant state without RPC; `ExampleEnableDecisions` compiles the composition. Enrollment uses the command’s own SDK unit, never `transactions.FromContext`. Classified models are refused rather than released, so no admitted path releases twice. SDK tokens are opaque evidence, not authenticated server proof; the pinned protocol cannot atomically bind definitions or in-place history changes. The protected profile admits no validators (root seam row). See [Chronicle decision reads](/arc/go/backend/go/chronicle/decision-reads/) | | Watches, snapshots, automatic historical migration, durable operation recovery | Read-model watch and aggregate/operation surfaces | None | Not implemented | No protection or recovery promise from snapshot injection. Registered historical generations decode with their exact descriptors; unknown generations fail explicitly. No automatic migration, append or command retry | Chronicle.Go requests [#42](https://github.com/Cratis/Chronicle.Go/issues/42), [#43](https://github.com/Cratis/Chronicle.Go/issues/43), [#44](https://github.com/Cratis/Chronicle.Go/issues/44), and [#45](https://github.com/Cratis/Chronicle.Go/issues/45) are delivered in the pinned SDK. No fallback is retained for their missing APIs: catalogs, configured scope resolution, pre-ack command effects and append notifications are all used. Their issues remain open pending upstream release. The limitations above are integration or SDK contract boundaries, not silent substitutes for undelivered hooks. ## Hosting and discovery ledger All C# paths below use Arc `7c1e78075b737df64f69fddfaae83374f75e3612` and are relative to `Source/DotNET/Arc.Core/`. [Hosting issue 17](https://github.com/Cratis/Arc.Go/issues/17) tracks this slice. Evidence is Go-owned unless the bounded paired snapshot checkpoint is named explicitly; none establishes browser execution. | Feature | C# source | Go surface | Status | Evidence and caller implications | | --- | --- | --- | --- | --- | | Root composition and explicit registration | `ArcApplication.cs`, `ArcApplicationBuilder.cs` | NewBuilder, Build, registrar seams | Go-specific | `TestBuilderCombinedFrozenComposition`, `ExampleNewBuilder`; explicit typed adapters, no redundant AddArc/UseArc calls, no runtime package scanning; `TestBuilderNamespaceHintIsNotAnExportedMethod` pins root/child namespace defaults without exporting CommandNamespace (unreleased implementation hint removed) | | Build without activation | `ArcApplicationBuilder.cs` | Single-attempt Build | Implemented | `TestBuilderFailedAttemptIsTerminal`, `TestOpaqueSchemaRequiresOverrideWithoutActivation`; failure is terminal at root, use a new builder to retry | | POST Execute/Validate | `Commands/CommandEndpointMapper.cs` | Application command routes | Implemented | `TestCommandsExecuteValidateAndTransportFailures`, `TestNineTaskBoardConformanceCases`; validate invokes no business handler and emits no response | | Traditional action handlers | `Arc/Commands/CommandActionFilter.cs`, `Arc.Specs/Commands/for_CommandActionFilter/when_executing` (`7c1e780`, under `Source/DotNET`) | `NewActionHandler`, `ActionOptions[T]`, `ActionResult`, mounted with `Builder.Handle` | Go-specific | `action_handler_test.go`, `action_severity_test.go`, `ExampleNewActionHandler`: a case-insensitive `/validate` path binds and validates but never invokes the action or Raw; findings reject before the action; errors, panics, blocking and invalid-severity findings suppress Response and Raw with production redaction; zero and false responses are retained and nil omitted; Raw keeps its own status, headers and body; body, query and media limits; Warning policy with `X-Ignore-Warnings`; canceled requests never run the action. An explicit adapter replaces the MVC action filter: no command registry, Provide, catalog authorization, scope, transaction, response handlers or event consumers, so protect routes with middleware. Source-derived witnesses, not paired MVC execution. See [traditional actions](/arc/go/backend/go/commands/traditional-actions/) ([#35](https://github.com/Cratis/Arc.Go/issues/35)) | | Request-value merge into action bodies | `Arc/ModelBinding/FromRequestModelBinder.cs` (`BindModelAsync`, `DefaultValueChecker`), `Arc.Specs/ModelBinding/for_FromRequestModelBinder/when_model_is_provided_partially_by_both_binders.cs` (`7c1e780`, under `Source/DotNET`) | Opt-in `ActionOptions.FromRequest` | Partial | `TestActionRequestPinnedDefaultMerge`, `TestActionHandlerExplicitRequestMergeAndContext`: default body members (missing, zero, false, null) take nondefault request values; body nondefaults, explicit empty strings and non-nil scalar pointers, including zero, win; request values are cloned. Go-specific: a caller-written FromRequest replaces ASP.NET binder discovery, wire names match exactly, null plain strings count as missing, and only flat built-in scalars and scalar pointers are admitted, not concepts, codecs, collections or embedded fields ([#35](https://github.com/Cratis/Arc.Go/issues/35)) | | GET/QUERY snapshots | `Queries/QueryEndpointMapper.cs` | Application snapshot routes | Implemented | `TestGETHEADQUERYReadersAndSnapshotNegotiation`, task-board nine cases; no SSE negotiation or synthetic wait | | Paired snapshot HTTP | `Queries/QueryEndpointMapper.cs`, `QueryableQueryRenderer.cs`, `BodyQueryRequestReader.cs`, `QueryStringQueryRequestReader.cs` (`7c1e780`) | `ContractTests/httpconformance`, real Application GET/QUERY endpoints | Partial | `TestPairedSnapshotHTTP`: 36 requests / 14 groups, 34 strict matches plus two exact ordinary-list page/size allowances described above. `TestReferenceActivation`, comparator, provenance and joined-lifecycle regressions fail closed. No command, authentication, HEAD, provider, streaming or browser parity claim | | Task-board HTTP conformance | Kotlin nine-case suite, Arc 22.14.0 / Arc.Kotlin `23c93a3` | `ContractTests/TestTaskBoardHTTPConformance` (in-process and child process) | Implemented | Nine cases covered; no live .NET comparison | | HEAD, routing and canonical paths | `Http/EndpointRouteHelper.cs`, endpoint mappers | Explicit HEAD and private ServeMux | Go-specific | `TestExactRoutingMethodsAndCanonicalPaths`, `TestRealListenerHEADAndGracefulShutdown`, `FuzzCanonicalRouting`; empty 404/405, lexical Allow, no path redirects, literal trailing slash; `TestRawCatchAllCannotStealFrameworkRoutes`, `TestRawExactOwnershipConflictsStillFailBuild`, `TestReservedUnknownRoutesBypassRawAuthentication` pin SPA/subtree fallbacks with Arc-table priority and reserved-prefix 404; reference `Http/HttpListenerEndpointMapper.cs` MapFallbackToFile | | Static files and SPA fallback | `StaticFileExtensions.cs`, `Http/StaticFileOptions.cs`, `Http/StaticFilesMiddleware.cs`, `Http/FallbackMiddleware.cs`, `Http/HttpListenerEndpointMapper.cs`; `Arc.Core.Specs/Http/for_StaticFilesMiddleware` and `for_FallbackMiddleware` under `Source/DotNET` | Public `Builder.Handle`, standard `fs.Sub` and `http.ServeContent`; example-local unexported `newStaticSite`, not an importable Arc API | Partial — embedded static-site composition | `ExampleBuilder_Handle_staticFiles` in [compiled example](https://github.com/Cratis/Arc.Go/blob/main/static_files_example_test.go) and `TestStaticSite...` in [HTTP tests](https://github.com/Cratis/Arc.Go/blob/main/static_files_test.go) cover a public, nonpersonalized site using immutable embedded assets or trusted read-only regular, seekable files with prompt filesystem operations. GET/HEAD serves `/assets/`; `/`, `/app` and extensionless `/app/...` select only the fixed `index.html`. Tests cover actual bytes/media/length and empty HEAD, missing asset/index, redacted filesystem errors, rejected methods, traversal/ambiguous paths, directories, known API success/error/denial/405, unknown API exclusion, mapped/unmapped/disabled/mixed-case reserved paths, terminal authentication rejection, no implicit raw-handler authorization, site-only header isolation, file closure, concurrent requests and joined host/application shutdown. Later read/close failures cannot retract publication and never append a shell. Deliberate Go differences are HEAD support, narrow fallback eligibility, reserved-route protection and site-only `no-store`, versus C# GET-only configurable roots/default files and broad first-root fallback. C# authority above is source/spec inspection, not new execution. See [hosting profile](/arc/go/backend/go/core/hosting/#serve-an-embedded-public-site): `fs.Sub`/`os.DirFS` are not symlink sandboxes; no writable/symlink filesystem confinement, browser, paired C#, broad-host or gap-free parity claim. The broader [issue 36](https://github.com/Cratis/Arc.Go/issues/36) profile remains Partial, not complete | | Credentials and anonymous exhaustion | `Http/AuthenticationMiddleware.cs` | Authentication ingress | Go-specific | `TestAnonymousAuthorizationDenialIs403AndCredentialFailureIs401`; terminal failed credentials 401, operation denial 403 even anonymous | | Tenant selection/membership | `Tenancy/*TenantIdResolver.cs` | Options.Tenancy/TenantResolver, pipeline membership | Implemented | `TestCommandsExecuteValidateAndTransportFailures`, `TestMiddlewareCompositionAndRawHandlerCapabilities`; header selection and borrowed raw metadata, membership stays pipeline-owned; `TestIngressErrorsAreLoggedWithoutRequestSecrets` pins invalid selectors as 400 malformedRequest findings (including queries), with original errors logged locally | | Correlation and immediate receipt | `Execution/CorrelationIdResolver.cs`, `OperationContextScope.cs` | Ingress, internal one-use receipt marker | Implemented | `TestTerminalCredentialsAndCorrelation`, `TestHTTPReceiptPrecedesAuthenticationAndNestedReceiptIsFresh`, `TestHTTPReceiptForwardingIsPrivateToEndpointPipeline`; header/envelope equality, endpoint receipt captured before authentication, nested and repeated raw-handler commands get distinct fresh receipts; middleware/readers/identity/discovery providers never receive the pipeline forwarding marker | | HTTP severity allowance | `Commands/CommandEndpointMapper.cs` | AllowedFromHeader forwarding | Go-specific | `TestAllowedSeverityHeaderFiltering`, `validation/TestAllowedHeader`; Error capped at Warning, repeats ignored, floors preserved | | Redaction and atomic publication | `ExceptionDetailRedactor.cs`, HTTP writers | Bounded encode-before-commit publication | Implemented | `TestAtomicPublicationFailuresPreserveStatusPrecedence`; failed data/state removed, denial/validation precedence retained, no command retry or rollback promise | | Fresh identity and legacy cookies | `Identity/IdentityProvider.cs` ExpireLegacyCookie, `Http/HttpListenerRequestContext.cs` RemoveCookie, `Identity/IdentityEndpointMapper.cs` | Typed/scoped details registration, me | Implemented | `TestIdentityFreshUnwrappedAndLegacyCookieExpired`, `TestLegacyCookieExpiryPreservesRemovalAttributesWithSecureDefault`, `TestLegacyCookieSecureExceptPlainHTTPLocalDevelopment`, `TestScopedIdentitySuppressesActivationAndCleanupSuppressesSuccess`; no cookie authentication, 401/403 stay empty, cleanup suppresses success; empty-value deletion at `/`, past expiry and no HttpOnly/SameSite/Max-Age. Go-specific security hardening: Secure by default (including remote HTTP behind HTTPS ingress); only plain-HTTP requests with a `localhost` or loopback `Host` and no direct or forwarded HTTPS signal omit Secure for development. `Forwarded`/`X-Forwarded-Proto` HTTPS only tightens the policy, including localhost backend Hosts; forwarded headers cannot relax it or establish identity. No new proxy-trust contract ([#66](https://github.com/Cratis/Arc.Go/issues/66)) | | Logging and SSE allocation hardening | `Queries/ClientObservableSSE.cs` diagnostics/framing (`7c1e780`); Go host logging boundary | `internal/logging.Sanitize`, builder and standalone command/query pipeline logger boundaries, `internal/streaming.SSEWriter` | Go-specific | `TestRequestCompletionSanitizesBeforeHostLogHandler`, `TestSnapshotFailureSanitizesBeforeHostLogHandler`, `queries/TestObservableFailureSanitizesBeforeHostLogHandler`, `commands/TestCallbackFailureSanitizesBeforeHostLogHandler`, `internal/logging/TestHandlerSanitizesGroupsAndPreservesErrorChains` remove CR/LF and other controls from strings, errors and Stringers, including grouped and derived attributes, before host handlers. `internal/logging/TestHandlerPreservesStringerJSONValues` preserves UUID correlation strings in JSON logs and sanitizes Stringer text before serialization. Printable text, error classification and `errors.Is`/`errors.As` traversal remain available; host level policy, record metadata and error returns are preserved. `internal/streaming/TestSSEFrameSizeValidatesBeforeAddingOverhead` checks the platform allocation ceiling before adding eight framing bytes; existing configured response/queue limits and frame bytes are unchanged. No caller migration beyond control-free diagnostic values and the Secure cookie policy above ([#66](https://github.com/Cratis/Arc.Go/issues/66)) | | Discovery exposure | `Introspection/DiscoveryExposure.cs` | IntrospectionOptions | Implemented | `TestDiscoveryExposureMatrix`, `TestDisabledCatalogsDoNotDisableIdentityDiscovery`; production unavailable without explicit capability, enabled controls only catalogs | | Catalog DTOs/summaries/schemas | `Introspection/*IntrospectionMetadata.cs` | Compiled catalogs, RegisterSchema | Partial | `TestCatalogsSchemasAndRoles`, `TestOpaqueSchemaRequiresOverrideWithoutActivation`; schema bounds and opaque-codec overrides replace complete .NET exporter; recursion/rich rules/polymorphism remain unsupported | | Development users/tenants | `Identity/IdentityEndpointMapper.cs` | AddUsersProvider/AddTenantsProvider | Implemented | `TestDiscoveryFixturesAreUnwrapped`, `TestDiscoveryProvidersAreLazyOrderedAndNotDeduplicated`; ordered lazy aggregation, empty arrays and no deduplication | | Explicit forwarded identity | `Identity/MicrosoftIdentityPlatformAuthenticationHandler.cs`, `Identity/UntrustedForwardedIdentityHeaders.cs` | MicrosoftIdentityPlatform, optional slog Logger | Partial | `TestForwardedIdentityExplicitTrustAndReservedClaims`, `authentication/TestUntrustedForwardedIdentityWarnsOncePerProcess`; unsigned-header trust opt-in, repeated/oversized input rejection, trust-off headers ignored with one value-free Warn per process across adapter instances; nil logger disables diagnostics; no JWT/OIDC verification | | Owned/embedded graceful lifecycle | `ArcApplication.cs`, `ArcApplicationExtensions.cs` | Start, Serve, Run, Shutdown, app pipelines | Go-specific | `TestStartRollbackIncludesFailingHook`, `TestShutdownCancelsThenJoinsDirectOperations`, real listener; explicit ownership, cooperative deadlines, no restart or detached disposal | | Backend operation diagnostics | `Observability/PipelineMetrics.cs`, `Observability/OperationOutcomes.cs` (`7c1e780`) | Optional `Options.Diagnostics`, command/query `PipelineOptions.Diagnostics`, `observability.Recorder` | Partial | `observability/recorder_test.go`, `commands/diagnostics_test.go`, `queries/diagnostics_test.go`, root `diagnostics_test.go` and `observability/ExampleRecorder`: bounded recorder-wide registered labels (default/max 1000, unknown/overflow `_other`), command execute/validate and snapshot-query outcomes, typed/scoped preflight, admission, nested attempts, unchanged receipts/results and explicit synchronous export outside producer locks. Cumulative counts/seconds replace C# meter histograms; a bounded recent-event ring may drop events without losing metrics. No payload/error values enter observations. `diagnostics_http_test.go` adds matched HTTP pre-dispatch failures and independent nested authentication calls; command/query nil-context regressions preserve one logical rejection and unchanged results. `queries/diagnostics_observation_test.go`, `diagnostics_retained_test.go` and internal forwarding specs distinguish opening, consumption, acknowledged data and actual joined cleanup. EOF remains success after Close; suppression, denial, obsolete/canceled/failed delivery do not increment served data; timed-out/panicking/pending cleanup remains retained until native ownership releases. Delivered metrics do not fill the event ring. No exporter runs during execution; no tracing provider, histogram buckets or complete observability parity claim. See [backend diagnostics](/arc/go/backend/go/core/diagnostics/) ([#33](https://github.com/Cratis/Arc.Go/issues/33)) | | Protected aggregate query health | `Queries/QueryHealth.cs`, `QueryHealthTracker.cs` (`7c1e78075b737df64f69fddfaae83374f75e3612`) | `Options.QueryHealth`, `QueryHealthName`, `/.cratis/queries/health`, optional native `queries.HealthReporter` | Partial | `query_health_test.go` and `ExampleQueryHealth`: absent by default including Development and raw fallbacks; copied nonempty administrative roles guard HTTP, backend Perform/PerformScoped/Open and hub subscriptions before state/source access. A private ordinary observable pipeline excludes application resource/factory/filter/validator state and rechecks each emission. Current plus one-second cancellation-aware consumer sampling starts no publisher worker. Synchronized aggregate query-label and protocol/state counts omit IDs, principals, clients, arguments, cached data and exceptions; native observation counts exclude health itself and overlap hub operations, so totals are neither additive nor globally atomic. Deliberately protected, opt-in and aggregate-only rather than C# anonymous detailed topology; no ping/pong/client details, automatic exporter or complete #33 parity claim. See [protected query health](/arc/go/backend/go/queries/query-health/) ([#33](https://github.com/Cratis/Arc.Go/issues/33)) | | Middleware and diagnostics | `Http/HttpListenerEndpointMapper.cs` HandleRequest, HTTP host middleware | Use, slog Logger, owned server ErrorLog | Go-specific | `TestMiddlewareCompositionAndRawHandlerCapabilities`, constructor-failure tests; first outermost, metadata before middleware, optional writer capabilities preserved; `TestIngressErrorsAreLoggedWithoutRequestSecrets` pins error-only logging at Warn for client selector faults and Error for server faults, with credential reasons/headers excluded | | Admitted request panic recovery | `Http/HttpListenerEndpointMapper.cs` HandleRequest, `ExceptionDetailRedactor.cs` | Ingress recovery and owned server logging | Go-specific | `TestIngressRecoversMiddlewareAndRawPanics`, `TestIngressDoesNotRewriteCommittedResponsesAndPropagatesAbort`, `TestOwnedServerUsesConfiguredSlogErrorLogger`, `TestSwitchingProtocolsCommitsTheObservedResponse`; before commitment, command/query redacted 500 or empty 500 for endpoints without envelopes; after commitment/hijacking, log only; http.ErrAbortHandler retains net/http abort semantics; nil logger stays silent | | Third-party host and middleware recipes | Hosting, command/query and validation contracts listed above (`7c1e780`); adapters are Go-specific | Single unpublished `recipes/` module; [recipe guides](/arc/go/backend/go/recipes/) | Go-specific | Chi/Gin/Echo and browser-origin mount tests preserve command/query envelopes, direct streaming, disconnect cleanup and coordinated HTTP/Arc shutdown with active SSE. Browser-origin regressions check trusted/attacker direct and hub WebSocket handshakes, hub SSE, warning preflight and correlation exposure. JWT tests verify credentials and access-token restrictions; OpenTelemetry tests bridge trace context and logs; playground validation tests cover portable-rule coexistence, wire names, anonymous embedding and memberless errors. Pinned dependencies remain outside runtime graphs; no browser execution, paired C# adapter run or arbitrary middleware/version claim ([#23](https://github.com/Cratis/Arc.Go/issues/23)) | | Default observable client, OpenAPI, browser parity | Observable handlers, OpenAPI, JavaScript clients (`7c1e780`) | Raw transport contracts and locked Node client lane | Partial | Default @cratis/arc WS/delta and explicit SSE/direct modes execute through `ContractTests/observables/frontend`; see the observable ledger for limits. The separate mounted observable hook lane has bounded explicit WS/Delta evidence. OpenAPI publication, browser and broader React parity remain unimplemented or unverified. Neither Node polyfills, independent class-test reduction nor mounted Node hooks prove browser behavior | Discovery deliberately applies `no-store, private` and merged `Vary: Cookie` to catalogs/schema as well as identity/development listings; current C# does not uniformly apply those headers. Raw handlers cannot steal reserved routes. Schema bounds fail explicitly, never publish empty placeholders for unsupported shapes. Framework recipes add no runtime dependencies; their installed-version adapter matrix remains application-owned. CI’s other OS/Go lanes are remote-only. ## Intentional differences and migration * Plain constructors and closures remain first-class. Arc runtime packages import no Fundamentals container; the DI contracts may appear transitively through execution. `examples/nocontainer` imports no DI package and exercises the foundation flows; `scripts/check-no-container.py` enforces the dependency boundary. * Dependency keys are exact, with no assignability search or root resolve. Fundamentals owns the optional container and graph validation. Use borrowed interface forwarding for one owned concrete instance; Arc forwards operation cleanup at most once even for a non-idempotent adapter. The unreleased `services` and `execution.Run` APIs are removed rather than wrapped. Use `RunWithResources`, `ResourcesFrom` and guarded `execution.Resolve`, or plain `ResourcesAs`. * Supply explicit stable namespaces/type names instead of CLR discovery. Moving Go source does not change routes or subscription names. Full descriptors will grow with pipeline/generator work; v1 does not pretend to describe unsupported schema. * Resolve rejects duplicate identities and case-insensitive method/path collisions rather than silently skipping a performer. Give each conflicting artifact an explicit path. Wildcards, relative/escaped paths, whitespace, repeated explicit slashes and dot segments fail instead of adopting host-dependent behavior. Derived paths are also checked: root and trailing-slash command paths are rejected because C#‘s literal `/validate` concatenation yields repeated slashes. Use `/create`, not `/create/`; configure a non-root conventional command route. * A zero result fails closed (unauthorized); use success constructors or explicitly set `Authorized` and query `Ready`. C# default construction is authorized/ready. * A failed command never carries a response, even for a non-nullable scalar. This avoids C# `ClearResponse` retaining `default(T)` under null-only omission. * Binding is case-sensitive like C# Arc’s default body serializer; use the exact wire name. Only exact repeats of declared names are rejected rather than using C#‘s last-value-wins behavior. Case variants are unknown fields and do not fail. * Present-null Optional model properties are written as null to preserve presence through a round trip. C# `WhenWritingNull` omits ordinary null properties instead. Consumers must handle explicit null; use missing Optionals when omission is wanted. * Claim tenancy ignores unauthenticated principals and returns NotSet even if they carry tenant claims. C# `ClaimTenantIdResolver.cs` at `7c1e780` reads claims without checking authentication; Arc.Go requires an authenticated adapter before treating a claim as a selector. Membership remains a separate check. * Plain Go strings reject null, unlike C# reference-type strings under Arc’s default options (nullable-annotation enforcement is not enabled). Use `*string` or `Optional[string]` to accept null without confusing it with an empty string. * `time.Time` retains Go’s RFC3339Nano JSON codec: UTC `Z`, explicit offsets and up to nine fractional digits. C# DateTimeOffset uses `+00:00` at UTC and tick precision (at most seven digits); C# DateTime also has kind-dependent suffixes. Normalize to common precision/forms or provide a codec for exact DateTimeOffset spelling; this slice does not implement the full C# timestamp input grammar. * Arc.Go’s UUID and calendar/duration scalars alias Fundamentals.Go’s shared types; existing imports, constructors, parsers and wire formats are unchanged. `Concept[T]` is the shared interface declaration, recognized through cached `Underlying` metadata without executing application methods. Supply all four text/JSON codecs; marker-only and nested concepts fail preflight with inspectable Fundamentals errors. Unlike C# inheritance and converter-created instances, Go uses explicit methods and constructors. HTTP text binding and bounded discovery schemas are implemented; generated schema and proxy metadata remain later work. * Use invariant calendar text and dashed UUIDs. Go deliberately does not guess a server culture. Input forms outside those documented codecs must be normalized by the caller or a custom codec. * Paging totals outside the int32 page-count range return MinInt32; avoid such metadata. C# uses an unchecked conversion whose overflow behavior is not certified by these fixtures. In-range calculations follow its double-precision ceiling. * Arc’s naming policy preserves leading acronyms (`ID`, `URLValue`). Add `json:"id"` explicitly where the wire name should be lowercase. Plain `encoding/json` does not implement Arc’s model policy; use `serialization.Marshal`/`Unmarshal`. * Foundation serialization is a bounded explicit-registration fallback, not the future generated descriptor codec. Exported embedded fields use standard Go promotion; nil unexported embedded pointers cannot be allocated during binding. Go omission tags honor `IsZero` and standard empty-kind rules. JSON/text codecs own their behavior; pointer-receiver marshalers require addressable values. Nil model slices/maps/pointers are omitted like C# null properties; initialize slices/maps when an empty collection must be present. Framework envelope arrays are separately normalized to `[]`. Arbitrary Unicode escaping/property order is not a byte-identity promise; literal scalar fixtures and canonical envelope bytes are the tested boundary. ## Next slices ARC-GO-04 provides immutable execution metadata and explicit service scopes; 05 provides typed validation and container-neutral security foundations; 06/07 provide backend command/query execution through manual typed adapters; 08 adds hosting. Scoped policies and validators can resolve through guarded optional DI resources. Generated adapters and source diagnostics remain unfinished: issues [10](https://github.com/Cratis/Arc.Go/issues/10) and [13](https://github.com/Cratis/Arc.Go/issues/13) are partially delivered, and [12](https://github.com/Cratis/Arc.Go/issues/12) is not complete until required generated examples pass. Historical paired HTTP and real browser consumers belong after those boundaries exist. Chronicle integration must use Chronicle.Go’s transaction participant/owner contract, not a second Arc UoW. # Go releases > This repository is in early development and has no tagged Go release yet. Repository setup uses no-release; the first implemented, minor-labeled release… This repository is in early development and has no tagged Go release yet. Repository setup uses `no-release`; the first implemented, minor-labeled release will be v0.1.0. Experimental releases remain v0.x until an approved stable launch. ## Release intent Every PR has exactly one of `major`, `minor`, `patch`, or `no-release`. While v0.x, use minor for breaking experimental API changes and patch for compatible fixes. Describe breaking changes explicitly in the release notes. Dependabot PRs use `no-release`; a dependency change that needs publication requires a deliberate release-bound maintainer change. The merged PR body becomes the GitHub Release notes verbatim. Use the [contribution guide](https://github.com/Cratis/Arc.Go/blob/main/CONTRIBUTING.md) and PR template, and keep verification and review notes in a PR comment. ## Publication A push to main runs the Go and Markdown gates before preparing a release. The release action creates the immutable `vX.Y.Z` tag and GitHub Release only after its job succeeds. Dependent jobs read back the release and tag target, fetch the exact version through `proxy.golang.org`, verify the module archive, and request its pkg.go.dev page. Documentation rendering is asynchronous. There is no separate registry upload or Go publishing credential. GitHub tags are Go module versions; the workflow uses its scoped `GITHUB_TOKEN` for GitHub writes. Keep the canonical lowercase root module path, `github.com/cratis/arc.go`. The supported nested-module exceptions are `tools/` (`github.com/cratis/arc.go/tools`) and `integrations/chronicle/` (`github.com/cratis/arc.go/integrations/chronicle`), and `integrations/mongodb/` (`github.com/cratis/arc.go/integrations/mongodb`). Each module builds independently with `GOWORK=off`, without a sibling checkout, secrets, or local replacements. The workflow publishes only root `vX.Y.Z` tags. Independent `tools/vX.Y.Z`, `integrations/chronicle/vX.Y.Z`, and `integrations/mongodb/vX.Y.Z` tags are deferred until the nested-module release pattern in [Fundamentals.Go#16](https://github.com/Cratis/Fundamentals.Go/issues/16) is established. The generator and Chronicle adapter currently use fetchable develop revisions; MongoDB pins pushed Arc revision `a094266` through its fetchable pseudo-version and Fundamentals `v0.1.0`. A root tag does not release any nested module. MongoDB publication requires a released root pin and separately approved nested-module release policy; this admission does not change root publishing. Wait for Publish to finish before merging another release-bound PR: GitHub concurrency can replace a pending run even when running jobs are not cancelled. ## Major versions `GO_RELEASE_MAJOR_CEILING` defaults to 0. Set it to 1 only for a maintainer-approved v1 launch and refresh the PR policy check. A major release must be reviewed and merged by a human. Values above 1 are rejected: Go v2+ requires a `/vN` module path, changed imports, and a separately reviewed release design. The current workflow does not publish prereleases or nested modules. ## Recovery If proxy indexing fails after publication, rerun the failed jobs. A full rerun resolves the existing stable release for the same commit and indexes that tag without bumping again. Investigate any conflicting tag or release; never move or delete a published tag. Correct a bad release with a new version and, where appropriate, a Go `retract` directive. The first public-proxy installation and pkg.go.dev visibility can be verified only after a release exists. Central Cratis documentation publication requires registering this repository in Cratis/Documentation and setting the repository variable `DOCUMENTATION_ENABLED` to `true`; it is independent of Go module indexing. # WebSocket runtime dependency > The pinned WebSocket implementation and its runtime boundary. ## Dependency and boundary Arc.Go pins `github.com/coder/websocket` **v1.8.15**, the latest v1.8.x tag returned by the Go module proxy when this dependency was introduced. The tag resolves to `9c8faadccd1b679e811a79ce506f8a10237251ad`. Its module requires Go 1.23 and has no module dependencies; Arc.Go continues to require Go 1.26. The standard library has no WebSocket server. This implementation supplies context-aware complete-message reads and writes, fragmented input, message limits, RFC control frames, and close handling. Compression is disabled. All library imports belong to `internal/websockettransport`. The public `observable` and `queries` packages do not import that adapter. Importing root `arc` includes the adapter once WebSocket endpoints are wired; this is a core transport, not an optional fourth module. ## License and security The v1.8.15 `LICENSE.txt` grants use, copying, modification and distribution with preservation of its copyright and permission notice (**ISC**, not MIT). No upstream source is copied into Arc.Go. Module downloads retain that notice. The reviewed module has no transitive dependency closure. Pin and checksum changes remain subject to `go mod verify` and `govulncheck`; a pin does not promise immunity from future vulnerabilities. Arc.Go validates its exact same-origin/allowlist policy before invoking Accept. The adapter disables the library’s separate host-pattern policy only after that host check, avoiding a second policy with different scheme and wildcard rules. Application JSON Ping/Pong messages remain distinct from RFC control frames. This dependency checkpoint alone does not implement a transport endpoint or establish browser-client compatibility. # HTTP contract > The language-neutral Arc wire contract: routes, headers, envelopes, statuses, identity and validation values, and where the C# and JVM implementations differ. This page describes the shared wire contract verified for [C# on ASP.NET Core](/arc/backend/csharp/) and [Kotlin and Java on Spring Boot](/arc/backend/kotlin/). It covers the common routes, envelopes, headers, and statuses for clients targeting either host. The common sections apply to both implementations. Where behavior differs, see [Where the implementations differ](#where-the-implementations-differ), which names both behaviors so a client author can see the edge before hitting it. Arc for TypeScript A third implementation, [Arc for TypeScript](/arc/backend/typescript/) on Node.js, is in source preview. The statements on this page are verified for the C# and JVM implementations. Arc for TypeScript follows this contract for the capabilities it implements, and a paired suite checks a bounded set of routes against a C# host. Where it differs, for example by capping `X-Allowed-Severity` at Warning or by answering 401 rather than 403 to an anonymous caller on a protected operation when authentication handlers are configured, its [HTTP contract reference](/arc/backend/typescript/reference/http-contract/) and [capability reference](/arc/backend/typescript/reference/capabilities/#deliberate-differences) say so. This is the contract, not a host guide Configuration keys, framework registration, and runtime tuning are implementation concerns. See the [C# backend documentation](/arc/backend/csharp/) and the [Kotlin and Java backend documentation](/arc/backend/kotlin/) for those, and the [JVM conformance notes](/arc/backend/kotlin/reference/http-contract/) for the paired HTTP evidence that backs the JVM side. For Arc for TypeScript, see its [backend documentation](/arc/backend/typescript/). ## Routes and methods | Endpoint | Method | Contract | | --- | --- | --- | | Command route | `POST` | JSON command body; returns a command result envelope. | | `/validate` | `POST` | Runs authorization and validation without invoking the handler. | | Query route | `GET` | Arguments in query-string parameters; returns a query result envelope. | | Query route | `QUERY` | Structured JSON request body; enabled by default; responds with `Cache-Control: no-store`. | | Observable query route | `GET` | HTTP snapshot, direct SSE when the request accepts `text/event-stream`, or a direct WebSocket upgrade. | | `/.cratis/queries/ws` | WebSocket | Multiplexed observable-query hub. | | `/.cratis/queries/sse` | `GET` | Multiplexed SSE hub; the stream opens with a `Connected` message carrying the connection ID. | | `/.cratis/queries/sse/subscribe` | `POST` | Adds or revision-replaces a subscription on an established SSE connection. | | `/.cratis/queries/sse/unsubscribe` | `POST` | Cancels a subscription or records its revision tombstone. | | `/.cratis/commands` | `GET` | Command introspection metadata. Access differs by backend; see [the divergence note](#introspection-access-is-configurable-on-net). | | `/.cratis/queries` | `GET` | Query introspection metadata. Access differs by backend; see [the divergence note](#introspection-access-is-configurable-on-net). | | `/.cratis/queries/health` | `GET`, `QUERY` | Current observable connection and subscription health, served as an observable query. | | `/.cratis/me` | `GET` | Registered only when an identity details provider is registered. | | `/.cratis/identity-details/schema` | `GET` | Always registered; returns `{}` when no provider is registered. | | `/.cratis/users` | `GET` | Development-user discovery; returns an array, or `[]` when no provider contributes. Access differs by backend; see [the divergence note](#introspection-access-is-configurable-on-net). | | `/.cratis/tenants` | `GET` | Development-tenant discovery; returns an array, or `[]` when no provider contributes. Access differs by backend; see [the divergence note](#introspection-access-is-configurable-on-net). | Conventional routes are built from the configured route prefix (`api` by default), the artifact’s namespace with the configured number of leading segments skipped, kebab-cased, and the artifact name. An explicitly declared path is preserved exactly. The QUERY method is registered alongside GET for every query route, including observable ones, and can be switched off so query routes accept GET only. ## Request headers | Header | Behavior | | --- | --- | | `X-Correlation-ID` | The correlation header, configurable. A valid UUID is reused; a missing or invalid value is replaced. The effective UUID is echoed on the response under the configured name. See [Correlation](#correlation). | | `X-Allowed-Severity` | The maximum nonblocking validation severity for the request. See [the divergence note](#x-allowed-severity-parsing-and-reach) before relying on it. | | `x-cratis-tenant-id` | The tenant header, configurable. See [Tenant resolution](#tenant-resolution). | Header lookup is case-insensitive, as HTTP requires. ## Correlation Correlation is diagnostic identity. It never carries authority, and no part of the contract grants a caller anything on the strength of a correlation value. * An inbound correlation header value is reused only when it is a syntactically valid UUID. Anything else - including an empty value or arbitrary text - is replaced by a freshly generated identifier. * The effective value is always emitted in canonical UUID form, so client-supplied text never reaches a response header. * Every Arc response carries the configured correlation header. * The same value appears as `correlationId` in the command and query result envelopes, so a caller can correlate a response body with its transport header without parsing anything. ## QUERY body The QUERY request body carries what a GET would put in the query string: ```json { "arguments": { "name": "Ada" }, "paging": { "page": 0, "pageSize": 25 }, "sorting": { "field": "name", "direction": "desc" } } ``` `arguments`, `paging`, and `sorting` are all optional. `paging` takes `page` (zero-based) and `pageSize`; `sorting` takes `field` and `direction`. A QUERY response always carries `Cache-Control: no-store`. Argument names are matched case-insensitively against the query’s declared parameters, and a matched value is converted to the parameter’s declared type. `asc`, `ascending`, `desc`, and `descending` all select their direction case-insensitively, and any other value is rejected as `malformedRequest` with 400 rather than being sorted some other way. Over GET the same request is expressed with reserved query-string parameters `page`, `pageSize`, `sortBy`, and `sortDirection`. These four reserved keys are matched case-insensitively (`sortby` and `SORTBY` also work); every other parameter is an argument. ## Unknown fields A command body is deserialized into the command type by the host’s configured JSON reader. Neither implementation turns on strict unknown-property rejection for that reader, so a command body carrying a field the command does not declare executes the command with the extra field discarded. `{"value":"hello","unexpected":"extra"}` is accepted, and `unexpected` is dropped. This holds for the command route and its `/validate` sibling alike. The QUERY envelope is stricter on one implementation only - see [Unknown fields in the QUERY envelope and in query arguments](#unknown-fields-in-the-query-envelope-and-in-query-arguments). ## Observable query transport An observable query is a query whose result is a stream rather than a value. The same route serves three transports, selected by the request. ### HTTP snapshot A plain GET returns the stream’s current value as an ordinary query result with 200 when one can be read. When the stream has not yet produced a value, the response is a not-ready query result - `isReady: false`, no exception - with `202 Accepted`. A caller that would rather wait sends `waitForFirstResult=true`, optionally with `waitForFirstResultTimeout` in seconds, and the host waits for the first result within a bounded budget. Both parameters are reserved: they are never treated as query arguments. While waiting, expiry of that budget returns `408 Request Timeout` with an error envelope, and a stream that completes without ever producing a value returns 500. Not-ready is a transient state, not a failure. `hasExceptions` stays false so a client does not read a pending stream as a crash. ### Direct SSE A GET whose `Accept` header includes `text/event-stream` upgrades the same route to Server-Sent Events. Each emission is written as exactly: ```text data: {query result envelope} ``` That is the `data:` field prefix, a space, the JSON envelope, and two newlines. There is no event name and no `id:` field. ### Direct WebSocket A WebSocket upgrade on the same route carries a message envelope with a string `type` of `Data`, `Ping`, or `Pong`. A `Data` message carries the query result envelope in `data`; `Ping` and `Pong` carry a Unix-millisecond `timestamp`. ### Multiplexed hubs `/.cratis/queries/ws` and `/.cratis/queries/sse` multiplex many subscriptions over one connection. Hub messages use a shared envelope with exact PascalCase `type` values: `Subscribe`, `Unsubscribe`, `QueryResult`, `Unauthorized`, `Error`, `Ping`, `Pong`, and `Connected`, alongside `queryId`, `revision`, `payload`, `timestamp`, `keepAliveIntervalMs`, and `supportsSubscriptionRevisions`. A hub connection opens with a `Connected` message whose `payload` is the server-assigned connection ID, and which carries `keepAliveIntervalMs` and `supportsSubscriptionRevisions: true`. SSE clients pass that connection ID on the subscribe and unsubscribe POST bodies to correlate them with the stream. A subscribe payload is flat: ```json { "connectionId": "…", "queryId": "client-assigned", "revision": 1, "request": { "queryName": "MyApp.Authors.Listing.AllAuthors", "arguments": { "name": "Ada" }, "page": 0, "pageSize": 25, "sortBy": "name", "sortDirection": "desc", "transferMode": "delta" } } ``` `arguments` is a map of string values. `revision` is optional; when present it must be a positive integer no greater than 9007199254740991, the JavaScript safe-integer limit, and it orders operations for one `queryId` so a duplicate is idempotent, a stale message is ignored, and an unsubscribe tombstone can arrive before a delayed subscribe. Result and terminal messages echo `queryId` and `revision`. `transferMode` is matched case-insensitively. `full` sends each snapshot without a change set; `delta` sends a full first snapshot and then only a change set. It is a preference about how much of each snapshot travels, not part of what the subscription means, so a textual value outside the known set is served exactly as one that sent no `transferMode` at all: snapshot plus change set on every result. Each subscription is authorized independently by the ordinary query pipeline. A subscription the caller may not have terminates with an `Unauthorized` message for that `queryId` without disturbing the connection’s other subscriptions. An unknown SSE connection ID returns 404. ## Tenant resolution A tenant is resolved per request from configured sources - most commonly the `x-cratis-tenant-id` header, with a query parameter, the request host, a principal claim, or a fixed value as alternatives. The resolved tenant is established for the request before the command or query runs, and applies to HTTP requests, SSE subscriptions, and WebSocket handshakes alike. ## Authentication and introspection When authentication handlers are registered, they run in order and the first handler that recognizes the request decides the outcome: * An **authenticated** result supplies the request principal, and the chain stops. * A **failed** result is terminal. No later handler runs, and no later handler can override the rejection with a success. The failure surfaces only as a generic 401. * An **anonymous** result means the handler did not recognize the request and lets later handlers try. Anonymous is the final outcome only when no handler recognized the request at all. An artifact marked to allow anonymous access proceeds without an authenticated result. The introspection, development-user, development-tenant, and identity-schema routes are the discovery routes. The JVM serves them anonymously; the C# host serves them anonymously only in Development by default (see [Introspection access is configurable on .NET](#introspection-access-is-configurable-on-net)). `/.cratis/commands` returns, for each command, its name, namespace, route, type, a single-line documentation summary taken from the source language’s doc comments (empty when the artifact carries none), and a JSON schema for the payload. `/.cratis/queries` returns the same for each query plus its fully qualified query name and an arguments schema whose `required` list holds the parameters the query actually requires. Introspection exposes your operation surface These endpoints expose operation names, types, routes, and schemas to any caller who can reach them, regardless of whether that caller may execute the operations. The C# host requires an authenticated caller outside Development by default. On the JVM they are always anonymous: restrict these exact paths at trusted ingress if that metadata is sensitive. ## Command result envelope | Field | Type | Notes | | --- | --- | --- | | `correlationId` | UUID | Always present. | | `isAuthorized` | Boolean | Authorization outcome. | | `validationResults` | Array | Always present, including when empty. | | `exceptionMessages` | Array | Always present; production redaction replaces detail. | | `exceptionStackTrace` | String | Empty when redacted or absent. | | `authorizationFailureReason` | String | Empty when absent. | | `isValid` | Boolean | True only when `validationResults` is empty, regardless of severity. | | `hasExceptions` | Boolean | True when `exceptionMessages` is nonempty. | | `isSuccess` | Boolean | Authorized, valid, and exception-free. | | `response` | Any | Omitted when null. A failed result never carries a response. | `isValid`, `hasExceptions`, and `isSuccess` are derived, not independent inputs. A response produced by a handler is taken back before serialization when the command did not ultimately succeed, so a client never receives a value it must not act on alongside a failure. Outside Development, exception messages and stack traces are replaced by a generic message before serialization. The full detail is logged server-side first, and the correlation ID is retained, so `hasExceptions` - and therefore the status code - is unchanged by redaction. ## Query result envelope | Field | Type | Notes | | --- | --- | --- | | `correlationId` | UUID | Always present. | | `data` | Any | The query data; omitted when null. | | `isReady` | Boolean | False for an observable query that has not produced its first result. | | `isAuthorized` | Boolean | Authorization outcome. | | `validationResults` | Array | Always present, including when empty. | | `exceptionMessages` | Array | Always present; production redaction replaces detail. | | `exceptionStackTrace` | String | Empty when redacted or absent. | | `paging` | Object | Always present. | | `changeSet` | Object | Present only for a delta-mode observable emission. | | `isValid` | Boolean | True only when `validationResults` is empty. | | `hasExceptions` | Boolean | True when `exceptionMessages` is nonempty. | | `isSuccess` | Boolean | Ready, authorized, valid, and exception-free. | `paging` carries `page` (zero-based), `size`, `totalItems`, and a calculated `totalPages` that is zero when `size` is zero and otherwise `totalItems` divided by `size`, rounded up. When `changeSet` is absent, `data` is the complete current snapshot. ## HTTP statuses | Condition | Status | | --- | --- | | Successful command or query | 200 | | Query not ready | 202 | | Validation failure, malformed request, or an unresolvable command dependency | 400 | | Authentication failure | 401 | | Command or query authorization failure | 403 | | Observable wait for the first result timed out | 408 | | Pipeline or host exception | 500 | Status selection is ordered, and the order matters when a result carries more than one kind of failure: success first, then authorization failure (403), then validation failure (400), then not-ready (202), then exception (500). A failed result never exposes a response value. A malformed or wrong-typed request body is a client error, not a server fault: it becomes a validation failure carrying the `malformedRequest` reason without echoing arbitrary parser messages. On C# model-bound execute and `/validate` routes, authentication, roles, scheme selection, and explicitly resource-free policies run before that failure is reported: denied callers receive 403, while admitted callers receive 400 with the normalized JSON member path when available. If any named policy needs a resource, the policy set is skipped for the unbindable body; no authorization command filters run. Arc-owned explicit-offset refusals have a showable message. See [C# command input binding](/arc/backend/csharp/commands/input-binding/) for the exact policy classification, member normalization, and MVC differences. This C# precedence and member behavior is not a guarantee about the JVM implementation. ## Identity contract `/.cratis/me` returns 401 for an unauthenticated principal, 403 when the details provider rejects the caller, and 200 on success with: ```json { "id": "…", "name": "…", "isAuthenticated": true, "isAuthorized": true, "roles": ["…"], "details": { } } ``` `details` is application-specific and its shape is the one described by `/.cratis/identity-details/schema`. The identity is always derived from the authenticated principal of the request. Clients read it from this response body; neither the C# nor the JVM implementation treats an identity cookie sent by the client as input. Whether a successful response also sets a `.cratis-identity` cookie differs between the implementations - see [the identity cookie](#the-identity-cookie-is-only-written-by-the-jvm). Arc’s frontend packages read it only when `/.cratis/me` answers 404, as a transitional fallback. ## Validation result Every validation result carries: | Field | Type | Notes | | --- | --- | --- | | `severity` | Number | See the severity table below. | | `message` | String | A developer-facing diagnostic, free to change. | | `members` | Array of string | The member names the result applies to. | | `state` | Any | Optional, application-supplied. | | `reason` | String | What composed the result. Defaults to `rule`. | | `reasonDetail` | String | Optional finer identity within `reason`. | | Severity | Wire value | | --- | --- | | `Unknown` | 0 | | `Information` | 1 | | `Warning` | 2 | | `Error` | 3 | `reason` is an **open set**, not an enum. Rejections are composed in Arc, in Chronicle, and in application code, and a closed set would make every new kind a breaking change for whoever switches over it. A client must tolerate values added later. The current values are: | Reason | Meaning | | --- | --- | | `rule` | An application-authored rule rejected the input. The message is the author’s; show it. | | `concurrencyViolation` | The target moved on since it was read. Retryable: re-read and resubmit. | | `constraintViolation` | A store-level constraint rejected the write. | | `validatorFailed` | A validator threw, and the framework substituted a result. Nothing of the authored rules survives. | | `dependencyUnavailable` | Something the command needed was not available, so no rule was ever evaluated. | | `malformedRequest` | The request itself could not be read or bound. No rule was reached. | | `frameworkFailure` | The framework could not fulfill a command contract or complete its effects. Framework-composed; the message is not meant for end users. | `reason` is the machine-readable counterpart to `message`, and `reasonDetail` says which specific thing within that category produced the result - which constraint, for example - so a client can branch on identity instead of matching prose. `isValid` is false whenever `validationResults` is nonempty, **regardless of severity**. Severity filtering happens before results reach the envelope, driven by the `X-Allowed-Severity` header and the artifact’s own settings; anything that survives that filter is blocking. `message`, `members`, `state`, `reason`, and `reasonDetail` are all client-visible and are not redacted the way exception detail is. Keep secrets out of every field. ## Where the implementations differ Everything above is common. The following is not, and each entry matters to someone writing a client or reasoning about security. Each one names what the C# implementation does and what the JVM implementation does. These comparisons do not claim TypeScript parity: for the source preview’s verified behavior and deliberate differences, use its [HTTP contract reference](/arc/backend/typescript/reference/http-contract/) and [capability reference](/arc/backend/typescript/reference/capabilities/). ### The identity cookie is only written by the JVM * **C#**: `/.cratis/me` does not set a cookie. It ignores any `.cratis-identity` cookie the client sends, and expires it. Earlier versions wrote a client-readable cookie and trusted it ahead of the principal; see [migrating from the identity cookie](/arc/backend/csharp/identity/migrating-from-the-identity-cookie/). * **JVM**: a successful response sets a client-readable (`HttpOnly=false`) `.cratis-identity` cookie holding the Base64-encoded response JSON, `SameSite=Lax` with `Path=/`, and a `Secure` policy documented in the [JVM conformance notes](/arc/backend/kotlin/reference/http-contract/). It is not used to establish the principal. ### SSE connection ownership is compared on a different set of values Both implementations verify that the caller of an SSE control POST is the caller that opened the GET stream, and both answer a mismatch with 404 - the same status as an unknown connection, so neither endpoint confirms that someone else’s connection ID exists. What they compare is not the same. The SSE control endpoints take a `connectionId` in the POST body and act on the stream it names. * **C#**: the identity that opened the GET stream is captured once, while that request is still in flight, and each subsequent POST is compared against it on the identity identifier claim and authentication state. The display name and the resolved tenant are not compared: the name is mutable and a control POST is authorized with its own tenant rather than the connection’s. * **JVM**: the handshake captured when the GET stream opened is compared with the handshake of the POST request on principal ID, principal name, authentication state, and resolved tenant. A connection opened by an unauthenticated caller carries no identity to bind to on either implementation, so ownership cannot be distinguished between anonymous callers. **Why it matters**: a client may not assume a control POST issued under a different identity will be honored, and may not treat 404 as proof that a connection does not exist. A connection whose caller has been re-authenticated under the same identity - a token refresh while the stream stays open - keeps working on C#; on the JVM a change to the principal name or the resolved tenant ends it. Do not treat a connection ID as a bearer-style secret on either implementation. ### Query health is anonymous on .NET `/.cratis/queries/health` reports connection and subscription identifiers, remote IP addresses, user agents, and user identities. * **C#**: the health read model is declared `[AllowAnonymous]`, so the endpoint is reachable without credentials. * **JVM**: the route requires an authenticated caller whenever authentication handlers are configured. **Why it matters**: a client may not assume the endpoint is protected. On a C# host, restrict the path at ingress if the diagnostics are sensitive. ### Observable HTTP snapshot readiness Both implementations return 200 when a current value can be read and 202 when it cannot, but what counts as readable differs with the stream type each runtime uses. * **C#**: the host reflects for a `Value` property on the streaming result. A `BehaviorSubject`-shaped observable exposes one and returns 200; a MongoDB-backed subject does not, so a GET returns 202 even when data exists in the collection. * **JVM**: a `StateFlow` already holds a value and returns 200; a cold `Flow` or a JDK `Flow.Publisher` has nothing to serve yet and returns 202. Arc’s own `/.cratis/queries/health` is a worked example: on C# it is an observable query whose returned subject exposes no `Value` property, so a plain GET answers 202 with a not-ready envelope even though health data exists. Accept `text/event-stream`, upgrade to WebSocket, or pass `waitForFirstResult=true` to read it over HTTP. **Why it matters**: a client that treats 202 as an error will break against perfectly healthy backends. Treat 202 as “subscribe, or retry with `waitForFirstResult=true`”. ### Introspection metadata depth * **C#**: command metadata is `name`, `namespace`, `route`, `type`, `documentationSummary`, and `payloadSchema`. Query metadata is the same plus `fullyQualifiedName` and `argumentsSchema`. Authorization, per-property validation metadata, transport, paging support, and HTTP-method preference are not reported. * **JVM**: reports the above and additionally authorization, properties, and validation metadata for commands, and transport, paging support, and HTTP preference for queries. Query parameter metadata reports `hasDefault` for a defaulted Kotlin parameter, and the arguments schema excludes defaulted parameters from `required`. UUID and textual `java.time` terminals - `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`, `OffsetDateTime`, `ZonedDateTime`, `OffsetTime`, `Duration`, and `Period` - appear as scalar `string` schemas, including as collection elements, rather than object schemas. The reported `route` is not necessarily callable. C# query introspection applies an explicit `[Path]` if present, but it does not reflect all final route deduplication or replacement decisions. **Why it matters**: tooling built against the JVM’s richer metadata degrades rather than fails on a C# host, but tooling that *requires* `hasDefault` or authorization metadata will find it absent, and tooling that treats an introspected `route` as a callable URL may miss routes replaced during mapping. Neither implementation exposes a default expression or an invented default value. ### Unknown fields in the QUERY envelope and in query arguments * **C#**: the QUERY envelope is deserialized permissively. An undeclared envelope field is ignored, and a query argument whose name matches no declared parameter is carried through as a raw string rather than rejected. * **JVM**: Arc validates the envelope’s field set itself - only `arguments`, `paging`, and `sorting`, with only `page`/`pageSize` and `field`/`direction` inside them - and rejects an undeclared field with 400 and `malformedRequest`. An argument name matching no declared parameter, and two argument names colliding after case folding, are rejected the same way. Page values must be nonnegative. **Why it matters**: a client typo in the envelope or an argument name fails loudly on a JVM host and silently on a C# host. Do not rely on either behavior; send exactly the fields the query declares. ### `X-Allowed-Severity` parsing and reach * **C#**: the header is read on the command route only, and only as an integer. A non-numeric value such as `Warning` is silently ignored, and so is an out-of-range number. * **JVM**: the header is accepted as a case-insensitive severity name or as the numeric wire value, on commands, queries, and hub subscriptions. A value that parses as neither is rejected as `malformedRequest`. **Why it matters**: send the numeric wire value - `0`, `1`, `2`, or `3` - if the header has to work on both. Do not send a name, and do not assume it affects queries. ### Admission control and request limits * **C#**: there is no connection or subscription admission control and no request-body size gate in Arc itself. 429, 503, `Retry-After`, and 413 are not produced by Arc; any such response comes from the surrounding host or infrastructure. * **JVM**: the host bounds concurrent Arc operations, physical observable connections, subscriptions per connection, outbound frames, inbound WebSocket message bytes, connection lifetime, and retained revision tombstones. Exhaustion fails closed: 503 with `Retry-After` for connection or request admission, 429 for SSE subscription exhaustion. A command or QUERY body exceeding the configured byte limit returns 413, counted while streaming even when `Content-Length` is absent. **Why it matters**: a client that handles 429 and 503 with backoff works on both; a client that does *not* will appear to work against C# and then fail under load against a JVM host. ### Unsupported HTTP methods * **C#**: under ASP.NET Core hosting, a matched route with an unsupported method returns 405 with an `Allow` header, from ASP.NET Core routing. Under Arc’s self-hosted `HttpListener` host, routes are keyed by method and path together, so an unsupported method falls through to 404. * **JVM**: an unsupported method returns 405 with an `Allow` header. **Why it matters**: do not use 405 versus 404 to probe whether a route exists. ### Tenant resolution strategy and enforcement * **C#**: one resolver strategy is selected by configuration - header, query parameter, subdomain, claim, fixed, or development. There is no option to require a resolved tenant, so an unresolved tenant does not itself fail the request. * **JVM**: a configured resolver *chain* runs once per HTTP request, SSE subscription, or WebSocket handshake, over headers, query parameters, the server host, and captured principal claims. With `tenancy.required=true` an unresolved request returns 400, or fails the WebSocket handshake. An authenticated caller with tenant-membership claims who selects another tenant receives a generic 403. **Why it matters**: a client cannot infer tenancy enforcement from the protocol. Send the tenant header explicitly rather than relying on host-side inference. ### Authentication handler chain hosting * **C#**: Arc’s own `IAuthenticationHandler` chain is invoked by the self-hosted `HttpListener` host. Under ASP.NET Core hosting, authentication is ASP.NET Core’s own, and Arc’s anonymous metadata maps to ASP.NET Core’s allow-anonymous metadata. * **JVM**: Arc’s authentication filter runs the registered `AuthenticationHandler` and `AsyncAuthenticationHandler` beans for Arc routes, ordered ahead of the rest of the Arc pipeline and behind Spring Security’s chain. **Why it matters**: the 401 a client sees is the same either way, but *where* credentials are validated - and therefore which configuration governs them - is not. ### Introspection access is configurable on .NET * **C#**: `Cratis:Arc:Introspection` controls the discovery routes: `/.cratis/commands`, `/.cratis/queries`, `/.cratis/users`, `/.cratis/tenants` and `/.cratis/identity-details/schema`. `Enabled` (default `true`) maps or unmaps the two catalog routes. `IdentityDiscovery` (default `true`) maps or unmaps users, tenants and the identity-details schema. Set both switches to `false` to remove all discovery routes without discovery authentication enforcement checks or exposure warnings; `/.cratis/me` is unchanged. `RequireAuthentication`, when not set, serves the mapped discovery routes anonymously in Development and requires an authenticated caller elsewhere; outside Development a host that cannot authenticate callers leaves them unmapped (404). `true` requires authentication everywhere and `false` exposes them anonymously everywhere. An anonymous request to a protected route returns 401 on Arc.Core. `Roles` (default unset, implies authentication) makes a caller without any listed role receive 403 on Arc.Core. On ASP.NET Core, the response depends on the authentication scheme’s challenge or forbid behavior (for example, cookies can redirect). Unsigned forwarded identity headers (`x-ms-client-principal*`) authenticate nothing, on the catalog or anywhere else, until the host sets `Cratis:Arc:TrustForwardedIdentityHeaders` (default `false`); a request carrying only those headers is anonymous and gets 401 from a protected catalog. The older `Introspection:TrustForwardedIdentityHeaders` is an obsolete alias that turns on the host-wide setting. * A custom handler that relies on forwarded headers must check the same opt-in itself and return an anonymous result without it, so the request returns 401. * With no authentication handler at all, a request to an endpoint marked as requiring authentication fails with `AuthenticationRequiredWithoutHandlers` rather than proceeding. * **JVM**: the introspection routes are always anonymous; there is no option to unmap or protect them. **Why it matters**: a client or tool reading the catalog can get 401 or 403, or a missing route, from a C# host but not from a JVM host. Only opt in to forwarded identity headers behind trusted ingress that authenticates callers, strips client-supplied identity headers, and prevents direct backend access. ### Correlation identifier edge cases * **C#**: the all-zero UUID is treated as not-set and replaced with a generated identifier. Correlation is established by the Arc endpoint handlers, so it covers Arc routes. * **JVM**: a servlet filter registered on `/*` establishes one identifier for every request reaching the host, whether Arc owns the route or not, ordered ahead of Spring Security and Arc authentication. The all-zero UUID parses as a UUID and is reused. **Why it matters**: do not send the zero UUID and expect it back. The identifier you observe on a C# host may differ from the one you sent in exactly that case. ### Development user and tenant discovery * **C#**: `/.cratis/users` and `/.cratis/tenants` concatenate the results of every registered provider in discovery order, without deduplication. * **JVM**: results are aggregated from ordered provider beans and deduplicated by identifier - the first item for each identifier wins. **Why it matters**: a client rendering a development user picker may see duplicate identifiers on a C# host if two providers contribute the same one. ## Related * [C# backend documentation](/arc/backend/csharp/) * [Kotlin and Java backend documentation](/arc/backend/kotlin/) * [JVM conformance notes](/arc/backend/kotlin/reference/http-contract/) * [Arc for TypeScript HTTP contract reference](/arc/backend/typescript/reference/http-contract/) * [Glossary](/arc/glossary/) * [Understanding the proxy boundary](/arc/understanding-the-proxy-boundary/) * [Understanding identity and access](/arc/understanding-identity-and-access/) # Scenarios > Short, task-oriented recipes for the things you'll actually do with Arc — each assumes you know the basics and gets straight to the goal. These are recipes: “I need to do X — how?” Each one is short and assumes you’ve built a slice or two (the [tutorial](/arc/tutorial/) is the long way round). For the *why* and the full set of options, follow the links into the reference. The default recipes use standalone Arc over configured providers or application services. Start with standalone setup for [C#](/arc/backend/csharp/getting-started/), [Kotlin or Java](/arc/backend/kotlin/get-started/), or the [TypeScript source preview](/arc/backend/typescript/getting-started/); each recipe labels additional prerequisites and illustrative fragments. Check the preview’s [capability reference](/arc/backend/typescript/reference/capabilities/) before relying on a shared example. Chronicle-only behavior is explicitly separated. | Recipe | When you reach for it | | --- | --- | | [Validate a command](/arc/scenarios/validate-a-command/) | Reject malformed or duplicate input before it writes state | | [Return a result or an error](/arc/scenarios/return-a-result-or-error/) | A command needs to hand back more than “it worked” — a value, or a typed failure | | [Provide data to a command handler](/arc/scenarios/provide-data-to-a-command/) | A decision needs data you must fetch — keep the fetch out of `Handle` so it stays testable | | [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) | A decision depends on provider-owned state — resolve it by an explicit command key | | [Query data across slices](/arc/scenarios/query-related-data/) | A screen needs data that spans more than one feature | | [Execute a command from React](/arc/scenarios/run-a-command-from-react/) | Wire a form or button to a command through the generated proxy | | [Test a command](/arc/scenarios/test-a-command/) | Prove a slice works through the real pipeline — no HTTP, no database | | [Authorize a command or query](/arc/backend/csharp/authorizing-commands-and-queries/) | Restrict who may run a command or read a query | Event-sourced Arc slices have their own Chronicle-specific recipes, starting with [React to an event](/arc/backend/csharp/chronicle/react-to-an-event/) and [Add event sourcing to an Arc slice](/arc/backend/csharp/chronicle/add-event-sourcing/). Missing a recipe you expected? The [backend options](/arc/backend/) and [Frontend](/arc/frontend/) guides cover the long tail, and [Troubleshooting](/arc/troubleshooting/) catches the common snags. # Provide data to a command handler > Fetch the data a command's decision needs in a Provide method so Handle stays a pure, easily tested function of its arguments. **Goal:** your command’s `Handle()` needs data it doesn’t carry — usually data from another service, such as a credit bureau, pricing service, inventory lookup, risk model, or tenant configuration — and you don’t want that IO tangled into the decision logic, where it’s hard to test. ## Lift the IO out of `Handle` Add `Provide()` next to `Handle()` on C#, or `provide()` next to `handle()` on JVM and TypeScript. `Provide` fetches or computes the data the decision needs, and its return value is passed into `Handle()` as an argument. `Handle` can then be a pure function of its arguments. To separate the write side as well on the C# backend, return a [command operation](/arc/backend/csharp/commands/operations/) describing the required service work, or return events with the optional Chronicle integration. `Provide` does not make persistence automatic. Acquire, decide, then perform Use `Provide()` to acquire decision inputs, `Handle()` to decide, and — on the C# backend — returned command operations to perform inline side effects. Do not move writes into `Provide()` merely to make `Handle()` look pure: such direct writes are outside operation compensation. [Operation testing](/arc/backend/csharp/testing/command-operations/) covers the decision and real execution separately. `Provide` runs before `Handle`, after validation and authorization pass. Its parameters are resolved from dependency injection just like `Handle`’s, and `this` is the command, so it can read the command’s own data. The following are **alternative domain fragments**, not a complete loan system. `LoanId`, `ApplicantId`, `CreditScore`, `RiskBand`, `LoanAssessment`, and the bureau/risk/rates interfaces are application-owned types. Supply their declarations and DI implementations; import `Cratis.Arc.Commands.ModelBound`, `Cratis.Arc.Validation`, and `Cratis.Monads` for the Arc attributes and result examples. `LoanAssessment` is an ordinary response DTO, not a persisted approval or Chronicle event. * C# ```csharp [Command] public record AssessLoan(LoanId LoanId, ApplicantId Applicant) { public CreditScore Provide(ICreditBureau bureau) => bureau.GetScore(Applicant); public LoanAssessment Handle(CreditScore creditScore) => new(LoanId, creditScore); } ``` * Java ```java @Command public record AssessLoan(LoanId loanId, ApplicantId applicant) { public CreditScore provide(CreditBureau bureau) { return bureau.score(applicant); } public LoanAssessment handle(CreditScore creditScore) { return new LoanAssessment(loanId, creditScore); } } ``` * Kotlin ```kotlin @Command data class AssessLoan(val loanId: LoanId, val applicant: ApplicantId) { suspend fun provide(bureau: CreditBureau): CreditScore = bureau.score(applicant) fun handle(creditScore: CreditScore): LoanAssessment = LoanAssessment(loanId, creditScore) } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, currentServices } from '@cratis/arc.core'; @command() export class AssessLoan { @field(LoanId) loanId!: LoanId; @field(ApplicantId) applicant!: ApplicantId; // provide() takes no parameters, so it resolves its services from the execution scope. async provide(): Promise { const bureau = await currentServices().resolve(CreditBureau); return bureau.scoreFor(this.applicant); } handle(creditScore: CreditScore): LoanAssessment { return new LoanAssessment(this.loanId, creditScore); } } ``` ## Why this matters: testability Because the IO lives in `Provide`, testing the decision needs no mocking — construct the command and call the handler directly with the values it would have received: * C# ```csharp [Fact] void should_return_the_assessment() => new AssessLoan(LoanId.New(), ApplicantId.New()) .Handle(new CreditScore(800)) .ShouldBeOfExactType(); ``` * Java ```java @Test void handleReturnsTheAssessment() { var loanId = new LoanId(UUID.randomUUID()); var command = new AssessLoan(loanId, new ApplicantId(UUID.randomUUID())); var assessment = command.handle(new CreditScore(800)); assertEquals(new LoanAssessment(loanId, new CreditScore(800)), assessment); } ``` * Kotlin ```kotlin @Test fun `handle returns the assessment`() { val loanId = LoanId(UUID.randomUUID()) val command = AssessLoan(loanId, ApplicantId(UUID.randomUUID())) val assessment = command.handle(CreditScore(800)) assertEquals(LoanAssessment(loanId, CreditScore(800)), assessment) } ``` * TypeScript (preview) ```typescript import { describe, expect, it } from 'vitest'; import { AssessLoan } from './AssessLoan.js'; describe('when assessing a loan', () => { it('should return the assessment', () => { const command = new AssessLoan(); command.loanId = LoanId.create(); command.applicant = ApplicantId.create(); expect(command.handle(new CreditScore(800))).toBeInstanceOf(LoanAssessment); }); }); ``` `Provide` has a single job — acquire data — so it is easy to test in isolation too, with a stubbed credit bureau. ## Return more than one value On C#, return a tuple; on Kotlin, return a `Pair` to feed several typed handler parameters. On TypeScript, return `tuple(a, b)` from `provide()` and mark each `handle()` parameter with `@inject(provided(TypeA), provided(TypeB))`; Arc matches the values by runtime type. Returning a single object for `handle()` to destructure also works: * C# ```csharp public (CreditScore, RiskBand) Provide(ICreditBureau bureau, IRiskModel risk) => (bureau.GetScore(Applicant), risk.Band(Applicant)); public LoanAssessment Handle(CreditScore score, RiskBand band) => new(LoanId, score, band); ``` * Java ```java public CommandProvidedValues provide(CreditBureau bureau, RiskModel risk) { return CommandProvidedValues.of(bureau.score(applicant), risk.band(applicant)); } public LoanAssessment handle(CreditScore score, RiskBand band) { return new LoanAssessment(loanId, score, band); } ``` * Kotlin ```kotlin suspend fun provide(bureau: CreditBureau, risk: RiskModel): Pair = bureau.score(applicant) to risk.band(applicant) fun handle(score: CreditScore, band: RiskBand): LoanAssessment = LoanAssessment(loanId, score, band) ``` * TypeScript (preview) ```typescript // One typed object keeps RiskBand as its enum; provide() can also return tuple(score, band) for provided(...) handle parameters. async provide(): Promise { const bureau = await currentServices().resolve(CreditBureau); const risk = await currentServices().resolve(RiskModel); return { score: await bureau.scoreFor(this.applicant), band: await risk.bandFor(this.applicant) }; } handle({ score, band }: CreditProfile): LoanAssessment { return new LoanAssessment(this.loanId, score, band); } ``` In C#, `Provide` may be synchronous or `async` (`Task` / `ValueTask`). On the JVM it may be ordinary, `suspend`, or return a `CompletionStage`, and a `Triple` or `CommandProvidedValues` carries more than two values. ## Use cancellation when the work can stop * C# ```csharp [Command] public record AssessLoan(LoanId LoanId, ApplicantId Applicant) { public Task Provide(ICreditBureau bureau, CancellationToken cancellationToken) => bureau.GetScore(Applicant, cancellationToken); public Task Handle(CreditScore creditScore, CancellationToken cancellationToken) => Task.FromResult(new LoanAssessment(LoanId, creditScore)); } ``` * Java ```java @Command public record AssessLoan(LoanId loanId, ApplicantId applicant) { // No token parameter: Arc awaits the returned stage inside its command coroutine and cancels // that stage when the request is cancelled. It cannot stop work the stage does not own. public CompletionStage provide(CreditBureau bureau) { return bureau.scoreAsync(applicant); } public CompletionStage handle(CreditScore creditScore) { return CompletableFuture.completedFuture(new LoanAssessment(loanId, creditScore)); } } ``` * Kotlin ```kotlin @Command data class AssessLoan(val loanId: LoanId, val applicant: ApplicantId) { // No token parameter: a suspending provide or handle runs inside Arc's command // coroutine, so cancelling the request cancels the work it is awaiting. suspend fun provide(bureau: CreditBureau): CreditScore = bureau.score(applicant) suspend fun handle(creditScore: CreditScore): LoanAssessment { currentCoroutineContext().ensureActive() return LoanAssessment(loanId, creditScore) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, currentContext, currentServices } from '@cratis/arc.core'; @command() export class AssessLoan { @field(LoanId) loanId!: LoanId; @field(ApplicantId) applicant!: ApplicantId; // No token parameter: the execution context carries the request's AbortSignal. async provide(): Promise { const bureau = await currentServices().resolve(CreditBureau); return bureau.scoreFor(this.applicant, currentContext()?.signal); } handle(creditScore: CreditScore): LoanAssessment { currentContext()?.signal.throwIfAborted(); return new LoanAssessment(this.loanId, creditScore); } } ``` Cancellation differs by backend. In C#, `Provide` and `Handle` can each take a `CancellationToken` and Arc supplies the current command execution token — the request-aborted token for HTTP endpoints, or the token you pass to `ICommandPipeline`. You do not register `CancellationToken` in the service container; Arc treats it as part of the command execution context. TypeScript has no `CancellationToken`; inject the request’s `AbortSignal` with `@inject(abortSignal())` on `provide()` or `handle()`, or read `currentContext()?.signal`, and pass it to cancellable work. See [model-bound commands](/arc/backend/typescript/commands/model-bound/). On the JVM there is **no token parameter at all**: a `suspend` `provide` or `handle` already runs inside Arc’s command coroutine, so cancelling the request cancels everything it awaits. Check `ensureActive()` when a long computation has no suspension point of its own. ## Short-circuit before `Handle` runs `Provide` can stop the command before `Handle` is ever called. The table below shows C# return shapes; TypeScript returns `rejected(validation(...))` from `provide()` for a typed failure, as its tab demonstrates. | Return / do | Result | | --- | --- | | `ValidationResult.Error("…")` | command fails validation (HTTP 400) | | an `AuthorizationResult` that is not authorized | command is unauthorized (HTTP 403) | | throw | command fails with the exception (HTTP 500) | | a value | flows into `Handle` as an argument | Use `Result<,>` to reject or proceed when the data acquisition itself determines whether the command can continue — return the error to stop, or the value to continue: * C# ```csharp public Result Provide(ICreditBureau bureau) { var score = bureau.GetScore(Applicant); return score is null ? ValidationResult.Error("No credit history", [nameof(Applicant)]) : score; } ``` * Java ```java // An Object return lets provide hand back either the value handle consumes or a // control signal; a returned ValidationResult rejects before handle runs. public Object provide(CreditBureau bureau) { var score = bureau.score(applicant); return score == null ? ValidationResult.error("No credit history", List.of("applicant")) : score; } public LoanAssessment handle(CreditScore creditScore) { return new LoanAssessment(loanId, creditScore); } ``` * Kotlin ```kotlin // An Any return lets provide hand back either the value handle consumes or a // control signal; a returned ValidationResult rejects before handle runs. suspend fun provide(bureau: CreditBureau): Any = bureau.score(applicant) ?: ValidationResult.error("No credit history", listOf("applicant")) fun handle(creditScore: CreditScore): LoanAssessment = LoanAssessment(loanId, creditScore) ``` * TypeScript (preview) ```typescript // A rejected(...) outcome stops the command with 400 before handle() runs. async provide(): Promise> { const bureau = await currentServices().resolve(CreditBureau); const score = await bureau.findScore(this.applicant); return score ?? rejected(validation('No credit history', ['applicant'])); } handle(creditScore: CreditScore): LoanAssessment { return new LoanAssessment(this.loanId, creditScore); } ``` C# states the alternative in the return type with `Result<,>`. Arc.Kotlin recognizes the control value at runtime instead: a `provide` declared to return `Any` may hand back either the value the handler consumes or a `ValidationResult`, `AuthorizationResult`, or `CommandResult`, and Arc short-circuits on the control value. TypeScript uses an `Outcome` such as `rejected(validation(...))` instead of the C# `Result<,>` shape. A provided value that no handler parameter consumes is almost always a mistake. C#‘s ARC0005 analyzer flags it; TypeScript’s [`arc-core/arc0005`](/arc/backend/typescript/code-analysis/arc0005/) ESLint rule flags a direct `provide()` return when `handle()` takes no parameters. For ordinary command validation, including read-model existence checks, prefer a `CommandValidator<>`. Use `Provide()` when `Handle()` needs acquired data as an input to the decision. ## Provider-owned state can already be available With a configured by-key read-model provider and a usable command key, `Provide` need not fetch that model again. Arc resolves it as a parameter alongside the services it uses — handy when existing state determines *what* to fetch. Without those prerequisites, inject your application service or context and fetch explicitly: * C# ```csharp public Task Provide(OrderReadModel? order, IShippingRates rates) => order is null ? Task.FromResult(ShippingQuote.None) : rates.Quote(order.Destination, order.TotalWeight); ``` * Java ```java // An Optional parameter preserves owned read-model absence as Optional.empty(). public ShippingQuote provide(Optional order, ShippingRates rates) { return order .map(view -> rates.quote(view.destination(), view.totalWeight())) .orElse(ShippingQuote.NONE); } ``` * Kotlin ```kotlin suspend fun provide(order: OrderView?, rates: ShippingRates): ShippingQuote = if (order == null) { ShippingQuote.NONE } else { rates.quote(order.destination, order.totalWeight) } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, commandReadModel, inject, key, readModel } from '@cratis/arc.core'; @readModel() class LoanState { @field(String) id = ''; @field(Number) score = 0; } @command() class AssessFromCurrentState { @field(String) @key() id = ''; @inject(commandReadModel(LoanState)) provide(state: LoanState): number { return state.score; } handle(score: number): number { return score; } } // Register LoanState with the owning Chronicle or MongoDB integration before executing. ``` See [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) for how that resolution works and what a nullable parameter means. ## Cross-cutting values `Provide` is per-command. For a value that many handlers need — the current tenant’s settings, say — register it as a scoped service and take it as a `Handle` parameter. Each backend resolves declared handler or provider dependencies from its service container; on TypeScript, use `@inject(...)` or generated metadata for erased parameter bindings. ## When not to use it If `Handle` already has everything it needs from the command and its injected services, you don’t need `Provide`. Reach for it specifically when a decision depends on data you must fetch — most often external or application-service data — where moving the fetch out buys you the testability. ## See also * [Return a result or an error](/arc/scenarios/return-a-result-or-error/) — the shapes `Handle()` itself can return. * [Test a command](/arc/scenarios/test-a-command/) — testing a slice through the real pipeline. * [Validate a command](/arc/scenarios/validate-a-command/) — where `ValidationResult.Error` comes from. * [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) — the read model Arc resolves for you, in all three positions. # Query data across slices > Serve a screen that needs data spanning more than one feature — by filtering live queries around a relationship. **Goal:** a screen needs data that doesn’t live in a single slice — authors *with* their books, an order *with* its line items, anything where one thing owns a list of another. ## Keep the relationship explicit In a database-backed Arc slice, model the relationship the same way you would in the domain: the child carries the parent’s id, and the query filters by it. Arc’s part is making that query a typed, live endpoint and generating the React proxy that calls it. ## Do it These are fragments of the [standalone MongoDB book lesson](/arc/tutorial/books-and-relationships/); use its domain declarations, imports, replica-set configuration, and generated proxies. Neither the relationship nor the live query requires Chronicle. **For an owns-a-list relationship, keep the child keyed to its parent.** A book belongs to an author, so the `Book` read model carries `AuthorId`: * C# ```csharp [ReadModel] public record Book(BookId Id, AuthorId AuthorId, BookTitle Title) { public static ISubject> BooksForAuthor(AuthorId authorId, IMongoCollection books) => books.Observe(book => book.AuthorId == authorId); } ``` * Java ```java @ReadModel public record Book(BookId id, AuthorId authorId, BookTitle title) { public static Flow.Publisher> booksForAuthor( AuthorId authorId, @FromServices MongoObservableQuery queries ) { return MongoObservations.observe( queries, Book.class, Criteria.where("authorId").is(authorId.value())); } } ``` * Kotlin ```kotlin @ReadModel data class Book(val id: BookId, val authorId: AuthorId, val title: BookTitle) { companion object { @JvmStatic fun booksForAuthor( authorId: AuthorId, @FromServices queries: MongoObservableQuery ): Flow> = queries.observe(Criteria.where("authorId").`is`(authorId.value())) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { argument, query, readModel, service, type ObservableSource } from '@cratis/arc.core'; @readModel() export class Book { @field(BookId) id!: BookId; @field(AuthorId) authorId!: AuthorId; @field(BookTitle) title!: BookTitle; @query({ observable: true }, argument('authorId', AuthorId), service(BookRepository)) static booksForAuthor(authorId: AuthorId, books: BookRepository): ObservableSource { return books.observeForAuthor(authorId); } } ``` No attribute marks the identity — by convention the `Id` property is the read model’s identity. Arc separates the query method’s parameters into data and services: `authorId` is data, so it becomes a query parameter; the collection or `MongoObservableQuery` is a service, so it’s injected. C# tells them apart by type; the JVM marks service parameters with `@FromServices` and leaves caller arguments unannotated, and puts the query on the read model’s companion object as a `@JvmStatic` function. The generated proxy takes the data parameter either way: ```tsx // authorId is the generated Guid value, not String(author.id). const [books] = BooksForAuthor.use({ authorId }); ``` With MongoDB change streams configured, `Observe(...)` publishes updated data. The filter does not enforce parent existence; use server validation and database constraints for that separate requirement. Add a book for that author and the row updates without polling. For a list-with-details screen, let the author list call `AllAuthors.use()` and each row call `BooksForAuthor.use({ authorId: author.id })`. That keeps each slice small: the author slice owns authors, the book slice owns books, and the screen composes their generated proxies. ## See also * [Queries](/arc/backend/csharp/queries/) — observable and non-observable query methods, parameters, and filtering. * [Relate your slices](/arc/tutorial/books-and-relationships/) — this relationship built step by step. * [Execute a command from React](/arc/scenarios/run-a-command-from-react/) — consuming the query proxy. # Return a result or an error > Return response data or a typed validation failure from a standalone Arc command, and tell response handling apart from optional Chronicle event persistence. **Goal:** explicitly write through a database or service, then return a value the caller needs — or a typed rejection. Arc.Core does not append events just because a returned object has an event-like name. ## Choose a standalone result The return shapes in this table are **C# signatures**. The JVM and TypeScript fragments below show their corresponding handler contracts. | You want to… | Return | | --- | --- | | Complete without response data | `void`, `Task`, or `ValueTask` | | Give the caller a value | that value, or `Task` / `ValueTask` | | Succeed with data or reject validation | `Result` (possibly asynchronous) | | Give the caller several pieces of data | one ordinary response DTO containing those values | | Combine a response with server-side work | a tuple containing one ordinary response and operations or other server-handled values | The following **handler replacement** uses the [MongoDB tutorial’s setup and domain types](/arc/backend/csharp/getting-started/your-first-command/). On C#, add `using Cratis.Monads;` and `using Cratis.Arc.Validation;` to the command file: * C# ```csharp public async Task> Handle(IMongoCollection authors) { if (await authors.Find(author => author.Name == Name).AnyAsync()) { return ValidationResult.Error("An author with that name is already registered.", [nameof(Name)]); } await authors.InsertOneAsync(new Author(Id, Name)); return Id; } ``` * Java ```java // An exception implementing ValidationFailure carries validation feedback out of // the handler; its own message and stack trace are never exposed to the caller. public static final class AuthorAlreadyRegistered extends RuntimeException implements ValidationFailure { public AuthorAlreadyRegistered() { super("Duplicate author name"); } @Override public List getValidationResults() { return List.of(ValidationResult.error( "An author with that name is already registered.", List.of("name"))); } } public AuthorId handle(@FromServices AuthorRepository authors) { if (authors.existsByName(name)) throw new AuthorAlreadyRegistered(); authors.save(new Author(id, name)); return id; } ``` * Kotlin ```kotlin // An exception implementing ValidationFailure carries validation feedback out of // the handler; its own message and stack trace are never exposed to the caller. class AuthorAlreadyRegistered : RuntimeException("Duplicate author name"), ValidationFailure { override val validationResults: List = listOf( ValidationResult.error("An author with that name is already registered.", listOf("name")) ) } suspend fun handle(authors: AuthorRepository): AuthorId { if (authors.existsByName(name)) throw AuthorAlreadyRegistered() authors.save(Author(id, name)) return id } ``` * TypeScript (preview) ```typescript @inject(AuthorRepository) async handle(authors: AuthorRepository): Promise> { if (await authors.existsByName(this.name)) { return rejected(validation('An author with that name is already registered.', ['name'])); } await authors.save({ id: this.id, name: this.name }); return this.id; } ``` The backends reject differently, and it is worth being explicit about it. C# declares the alternative in the signature with `Result`. Arc.Kotlin has no `Result<,>` type and no handler return shape that carries both a client response type and a rejection, because the generated proxy and OpenAPI metadata need exactly one client response leaf. The JVM way is to throw an application exception implementing `io.cratis.arc.validation.ValidationFailure`: the command pipeline converts it into the same validation feedback a validator would produce, without leaking the exception message or stack trace. An ordinary exception still becomes an exception result. TypeScript’s `handle()` returns `rejected(validation(message, members))` for a typed failure, or the response value on success; it does not use C#‘s `Result<,>` signature. The insert is the persistent effect; the `AuthorId` is response data. The pre-check gives a friendly failure but is **not atomic**. Keep the database unique index from [validation](/arc/tutorial/validation/) and explicitly translate its duplicate-write failure if you need friendly errors under races. Other storage exceptions remain exception results; do not disguise them as ordinary validation. For rules that can reject before work begins, prefer a backend command validator; use a typed handler failure when the write/decision itself discovers the failure. `Provide()` can also [short-circuit with typed validation](/arc/scenarios/provide-data-to-a-command/). Separate an already-decided write from its response When the work and caller response are already decided, the C# backend can return a [command operation](/arc/backend/csharp/commands/operations/) alongside the ordinary response. Direct service calls remain supported when their result is needed to choose the response or typed rejection. Operation `Execute()` methods cannot return receipts or validation alternatives; moving a write into an operation does not automatically preserve write-dependent error handling. ## Read it on the client After regenerating the proxy, `execute()` returns a `CommandResult` (a response-bearing result for this signature). Check `isSuccess` before using `response`; otherwise inspect `validationResults`, `isAuthorized`, and exception information. The [command-result contract](/arc/frontend/core/commands/command-result/) documents these independently — success is not synonymous with absence of validation errors. ## Optional: event results with Chronicle On C#, only after configuring [Arc + Chronicle](/arc/backend/csharp/chronicle/) do registered-event response handlers append events. These are **integration signatures**, not replacements for a standalone write: | Integrated intention | Return shape and prerequisite | | --- | --- | | Append a registered event | `AuthorRegistered Handle()`; Chronicle resolves the source from the command | | Select the source in the response | `(AuthorId, AuthorRegistered)` only when `AuthorId` derives from Chronicle `EventSourceId` / `EventSourceId` | | Return caller data alongside an event | `(result, event)`; unhandled values remain response data | | Reject or append | `Result` | | Append several events | an enumerable of registered events; use `EventForEventSourceId` for explicit cross-stream targeting | The standalone tutorial’s `AuthorId : ConceptAs` is **not** an event-source identifier. An ordinary `Guid` in a response remains ordinary caller data; do not reinterpret it as an append target. See [Chronicle response identifiers](/arc/backend/csharp/chronicle/commands/returning-event-source-id/) for explicit-source contracts. ## See also * [Commands](/arc/backend/csharp/commands/) — supported handlers and pipeline behavior. * [Validate a command](/arc/scenarios/validate-a-command/) — choose the right rejection point. * [Test a command](/arc/scenarios/test-a-command/) — assert the actual write, not just a successful result. # Execute a command from React > Wire a React form or button to a command through the generated proxy — with CommandDialog handling instantiation, validation, and the buttons. **Goal:** a user clicks a button or fills a form, and a command runs — with validation feedback, a disabled-while-running button, and no hand-written API client. ## Call the proxy, don’t write a client After a configured Debug [proxy-generation build](/arc/backend/csharp/proxy-generation/getting-started/), Arc supplies typed command proxies. This recipe assumes the [standalone backend](/arc/backend/csharp/getting-started/) and [full-stack tutorial setup](/arc/tutorial/first-slice/), which supplies the Vite bootstrap, Components prerequisites, providers, and dialog composition. For an **inline form in an existing Vite app**, [frontend getting started](/arc/frontend/react/getting-started/) uses Arc’s `CommandForm` with Components fields, and shows the route without Components. You don’t write a `fetch`, redeclare the command’s shape, or wire validation — the proxy already knows the types, and `CommandDialog` drives the whole interaction. ## Do it 1. **Use `CommandDialog` for a form.** Pass the command constructor; the dialog instantiates it, renders the confirm/cancel buttons, and disables confirm while it executes. Use command form fields for the inputs: AddAuthor.tsx ```tsx import { useState } from 'react'; import { Guid } from '@cratis/fundamentals'; import { CommandDialog } from '@cratis/components/CommandDialog'; import { InputTextField } from '@cratis/components/CommandForm'; import { RegisterAuthor } from './Authors/RegisterAuthor'; // generated proxy export const AddAuthor = () => { const [id] = useState(() => Guid.create()); return ( command={RegisterAuthor} title="Add author" okLabel="Add" initialValues={{ id }}> value={i => i.name} title="Name" /> ); }; ``` Mount a new dialog instance per operation and unmount on close. `id` is stable during editing and fresh for the next registration; a required Guid is not automatically filled by the proxy. The field accessor `i => i.name` is a property on the generated type — rename it in C#, rebuild, and the frontend stops compiling until you fix it. 2. **Inject context the form needs to be valid via `initialValues`.** A parent id the user shouldn’t type goes here — not in `onBeforeExecute`, which fires too late to affect validity: This **form fragment** assumes the [book dialog’s imports and per-operation `bookId` state](/arc/tutorial/books-and-relationships/). Keep `authorId` as the generated `Guid`, not a string. ```tsx command={AddBook} title="Add book" initialValues={{ authorId, bookId }}> value={i => i.title} title="Title" /> ``` 3. **Validation surfaces itself.** Supported rules extracted from the command and its value types run client-side; the server remains authoritative, including async/custom/state-dependent rules that cannot be extracted, and `CommandDialog` renders the messages against the fields. You write none of that wiring. For executing a command without a dialog (a plain button, custom flow), the proxy exposes an execute call that returns a `CommandResult` — check `isSuccess` and read any returned value off it. ## See also * [Dialogs](/components/) — `CommandDialog`, `Dialog`, and the form-field components. * [Command Result](/arc/frontend/core/commands/command-result/) — handling the result in code. * [Validate a command](/arc/scenarios/validate-a-command/) — the rules that surface in the form. # Test a command > Exercise the standalone Arc pipeline in process, register its collaborators explicitly, and assert both results and effects without a web server or database. **Goal:** verify the command’s result and its actual effect without pretending that a successful result proves a database write. ## Use the standalone harness On the JVM the equivalent harness is `io.cratis:arc-testing`, added as a `testImplementation` dependency and driven from an ordinary JUnit 5 test. TypeScript’s source preview uses `@cratis/arc.testing` with `CommandScenario.for(...)` and an in-process command pipeline. Each backend registers collaborators and asserts actual effects; setup and assertion names differ. Reference `Cratis.Arc.Testing` in an xUnit spec project, together with `Cratis.Specifications.XUnit`, `NSubstitute`, and the usual xUnit runner/test SDK. `Cratis.Testing` is the wider integration meta-package; it is not required here. A dedicated spec project can run in Debug and Release; use `#if DEBUG` only when intentionally embedding specs in an application assembly that excludes them from Release. For decision-only cases, a direct `Handle()` call can be the simpler test. The [decision-and-pipeline lesson](/arc/backend/csharp/testing/command-decisions/) shows how the two approaches complement each other. This recipe deliberately tests service interaction through Arc. `CommandScenario` runs the real validation, authorization, `Provide`, and handler pipeline. It does **not** automatically fake your Mongo collection, context, or application services. Register all collaborators before the first `Execute` or `Validate`; the service provider is built lazily then. Dispose the scenario afterward. Test the decision separately from execution On the C# backend, for immediate inline side effects, prefer [command operations](/arc/backend/csharp/commands/operations/); the direct service-call example below remains supported. An operation-returning `Handle()` can be tested by inspecting its data without executing the work. It is pure only when it uses supplied inputs without I/O, clock reads, randomness, or mutable external state. [Operation scenarios](/arc/backend/csharp/testing/command-operations/) test actual execution and recovery with controlled collaborators, not automatic recording stubs. ## Define the service-backed example This example uses only the `AuthorId` and `AuthorName` concept declarations from the [backend lesson](/arc/backend/csharp/getting-started/your-first-command/), not its database-backed `RegisterAuthor`. Put these declarations in `Library.Authors` in the application project: * C# ```csharp using Cratis.Arc.Commands; using Cratis.Arc.Commands.ModelBound; using Cratis.Arc.Validation; using FluentValidation; namespace Library.Authors; public interface IAuthorRegistration { Task Register(AuthorId id, AuthorName name); } [Command] public record RecordAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IAuthorRegistration registration) => registration.Register(Id, Name); } public class RecordAuthorValidator : CommandValidator { public RecordAuthorValidator() => RuleFor(command => command.Name.Value).NotEmpty(); } ``` * Java ```java public interface AuthorRegistration { void register(AuthorId id, AuthorName name); } @Command public record RecordAuthor(AuthorId id, AuthorName name) { public void handle(AuthorRegistration registration) { registration.register(id, name); } } public final class RecordAuthorValidator implements BlockingCommandValidator { @Override public Class getCommandType() { return RecordAuthor.class; } @Override public List validate(RecordAuthor command, CommandContext context) { return command.name().value().isBlank() ? List.of(ValidationResult.error("An author needs a name.", List.of("name"))) : List.of(); } } // A Java validator is an ordinary class; the adapter is what Arc discovers as a bean. @Configuration public class RecordAuthorValidation { @Bean public CommandValidator recordAuthorValidator() { return new BlockingCommandValidatorAdapter<>(new RecordAuthorValidator()); } } ``` * Kotlin ```kotlin package library.authors import io.cratis.arc.artifacts.Command import io.cratis.arc.commands.CommandContext import io.cratis.arc.commands.CommandValidator import io.cratis.arc.results.ValidationResult import org.springframework.stereotype.Component interface AuthorRegistration { suspend fun register(id: AuthorId, name: AuthorName) } @Command data class RecordAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(registration: AuthorRegistration) { registration.register(id, name) } } @Component class RecordAuthorValidator : CommandValidator { override val commandType: Class = RecordAuthor::class.java override suspend fun validate( command: RecordAuthor, context: CommandContext ): List = if (command.name.value().isBlank()) { listOf(ValidationResult.error("An author needs a name.", listOf("name"))) } else { emptyList() } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, CommandValidator, inject, validator } from '@cratis/arc.core'; import { AuthorId } from './AuthorId.js'; import { AuthorName } from './AuthorName.js'; export abstract class AuthorRegistration { abstract register(id: AuthorId, name: AuthorName): Promise; } @command() export class RecordAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @inject(AuthorRegistration) handle(registration: AuthorRegistration): Promise { return registration.register(this.id, this.name); } } @validator(RecordAuthor) export class RecordAuthorValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.name).notEmpty(); } } ``` `IAuthorRegistration` / `AuthorRegistration` is an **application-owned interface**, not an Arc API. Production supplies its implementation; the spec supplies a substitute. If you test the Mongo tutorial command instead, supply its actual collection/validator dependencies or deliberately run a database integration spec. ## Assert the effect through the pipeline Create the spec in the test project: * C# ```csharp using System.Threading.Tasks; using Cratis.Arc.Commands; using Cratis.Arc.Testing.Commands; using Cratis.Specifications; using Library.Authors; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Xunit; namespace Library.Specs; public class when_recording_an_author : Specification { readonly CommandScenario _scenario = new(); readonly AuthorId _id = AuthorId.New(); readonly AuthorName _name = new("Ada Lovelace"); IAuthorRegistration _registration = null!; CommandResult _result = null!; void Establish() { _registration = Substitute.For(); _registration.Register(_id, _name).Returns(Task.CompletedTask); _scenario.Services.AddSingleton(_registration); } async Task Because() => _result = await _scenario.Execute(new RecordAuthor(_id, _name)); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] async Task should_register_the_author() => await _registration.Received(1).Register(_id, _name); void Destroy() => _scenario.Dispose(); } ``` * Java ```java import io.cratis.arc.generated.LibraryArcArtifactModule; import io.cratis.arc.testing.CommandScenario; import io.cratis.arc.testing.java.BlockingCommandScenario; import java.util.ArrayList; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; public class WhenRecordingAnAuthor { @Test void recordsTheAuthor() { var id = new AuthorId(UUID.randomUUID()); var name = new AuthorName("Ada Lovelace"); var recorded = new ArrayList(); AuthorRegistration registration = (registeredId, registeredName) -> recorded.add(registeredId); var scenario = new CommandScenario<>(new LibraryArcArtifactModule(), RecordAuthor.class) .addService(AuthorRegistration.class, registration); // BlockingCommandScenario owns a bounded dispatcher, so a JUnit test needs no coroutine. try (var blocking = new BlockingCommandScenario<>(scenario)) { blocking.execute(new RecordAuthor(id, name)).shouldSucceed(); } assertEquals(List.of(id), recorded); } } ``` * Kotlin ```kotlin package library.specs import io.cratis.arc.generated.LibraryArcArtifactModule import io.cratis.arc.testing.CommandScenario import kotlinx.coroutines.runBlocking import library.authors.AuthorId import library.authors.AuthorName import library.authors.AuthorRegistration import library.authors.RecordAuthor import java.util.UUID import kotlin.test.assertEquals import org.junit.jupiter.api.Test class WhenRecordingAnAuthor { @Test fun `records the author`() = runBlocking { val id = AuthorId(UUID.randomUUID()) val name = AuthorName("Ada Lovelace") val recorded = mutableListOf>() val registration = object : AuthorRegistration { override suspend fun register(id: AuthorId, name: AuthorName) { recorded += id to name } } val result = CommandScenario(LibraryArcArtifactModule(), RecordAuthor::class.java) .addService(AuthorRegistration::class.java, registration) .execute(RecordAuthor(id, name)) result.shouldSucceed() assertEquals(listOf(id to name), recorded) } } ``` * TypeScript (preview) ```typescript import { CommandScenario, type ScenarioCommandResult } from '@cratis/arc.testing'; import { deepStrictEqual } from 'node:assert'; import { afterEach, beforeEach, describe, it } from 'vitest'; import { AuthorId } from './AuthorId.js'; import { AuthorName } from './AuthorName.js'; import { AuthorRegistration, RecordAuthor, RecordAuthorValidator } from './RecordAuthor.js'; describe('when recording an author', () => { let scenario: CommandScenario; let result: ScenarioCommandResult; let registered: string[][]; let id: AuthorId; let name: AuthorName; beforeEach(async () => { registered = []; id = AuthorId.create(); name = new AuthorName('Ada Lovelace'); scenario = CommandScenario.for(RecordAuthor, RecordAuthorValidator); scenario.services.addSingleton(AuthorRegistration, { register: async (authorId: AuthorId, authorName: AuthorName) => { registered.push([authorId.toString(), authorName.value]); } }); result = await scenario.execute({ id, name }); }); afterEach(async () => { await scenario.dispose(); }); it('should succeed through the real command pipeline', () => { result.shouldBeSuccessful(); }); it('should register the author with the intended values', () => { deepStrictEqual(registered, [[id.toString(), name.value]]); }); }); ``` The first assertion checks the pipeline result; the second proves the handler called its collaborator with the intended values. Neither proves a production database mapping or index — test those at their integration boundary. The three harnesses differ in how the scenario is constructed and how it is torn down. C#‘s `CommandScenario` discovers the command from its type argument, exposes an `IServiceCollection`, and is disposable. Arc.Kotlin’s `CommandScenario` takes the KSP-generated `ArcArtifactModule` plus the command class — so the generated wiring itself is under test — registers collaborators one at a time with `addService(type, instance)`, and needs no disposal; `execute` is a suspending function, and the result carries `shouldSucceed`, `shouldBeInvalid`, `shouldBeUnauthorized`, and `shouldHaveResponse`. There is no NSubstitute on the JVM side, so a hand-written object expression stands in for the collaborator. TypeScript’s scenario registers the collaborator in `scenario.services`, executes with `await scenario.execute(...)`, and disposes with `await scenario.dispose()`; the fragment uses a recording fake. ## Check rejection independently In a separate spec, use the same service setup and execute the command with an empty author name. On C#, assert **both** `ShouldNotBeSuccessful()` and `ShouldHaveValidationErrors()`, and verify `Register` was not called; on the JVM, chain `shouldBeAuthorized().shouldBeInvalid()` so the spec pins *which* stage rejected the command. `Validate` / `validate` is available when you intentionally want pre-flight validation without invoking the handler; keep that action in a separate spec as well. For protected commands, set up the principal explicitly and assert `ShouldNotBeAuthorized()` / `shouldBeUnauthorized()` separately from validation. The [scenario reference](/arc/backend/csharp/testing/command-scenario/) and the [JVM in-process testing guide](/arc/backend/kotlin/guides/testing/) cover context setup and the complete assertion API. ## Optional Chronicle testing Only event-sourced commands need the [Chronicle testing extension](/arc/backend/csharp/testing/chronicle/). It adds event/read-model seeding and append assertions. Keep those specs labeled as integration behavior; standalone Arc tests assert database/application-service effects instead. # Use current state in a command > Resolve a read model by an explicit command key through MongoDB, EF Core, Spring Data or Chronicle, and choose how to handle absence and concurrency. **Goal:** decide against existing state without repeating the same lookup in a validator, `Provide()`, and `Handle()`. Arc can resolve a read model once in the command scope and share that instance between the positions the backend supports. This needs **both a usable command key and a provider that owns key-based resolution**. Marking the type `[ReadModel]` (C#), `@ReadModel` (JVM), or `@readModel()` (TypeScript) is not by itself a storage configuration. ## Declare the key and provider Standalone Arc resolves keys from `[Key]` or `ICanProvideKeyForCommand.GetKey()` in C#, from `@CommandKey` or `CommandKeyProvider.commandKey()` on the JVM, and from a registered `CommandKeyResolver`, `getKey()`, or `@key()` field in TypeScript. No backend infers a key merely from a property named `Id`. Declare at most one key: C# uses the first `[Key]` property it finds rather than rejecting a second. A composite application key can be composed by the provider interface, but the selected provider must still support the resulting stored key. ``` Key / CommandKey or a key provider found valid key, absent record Command resolved key owning read-model resolver shared command-scoped instance null or required-state failure validator Provide Handle ``` | Provider | What enables resolution | | --- | --- | | MongoDB (C#) | Configured Arc MongoDB integration discovers read-model candidates as fallback ownership and loads by mapped document id | | MongoDB (TypeScript) | `withMongoDB` registers a resolver for models listed in `readModels` and loads by their declared document key; applications can register their own resolver | | EF Core (C#) | A registered `ReadOnlyDbContext` owns a `[ReadModel]` entity through `DbSet`; resolution currently requires a single-property primary key | | Spring Data JPA / MongoDB (JVM) | The integrations contribute storage-neutral `CanResolveReadModelForCommand` providers for exact mapped `@ReadModel` types; JPA claims `DECLARED` ownership, MongoDB `FALLBACK` | | Chronicle (optional) | A projection or reducer supplies integration-specific ownership and event-source key resolution; TypeScript’s integration is experimental | A normal writable `BaseDbContext` like the tutorial’s `LibraryDbContext` is **not enough to establish EF command-read-model ownership**. Keep method-injecting that context for explicit lookups/writes, or configure a [read-only context](/arc/backend/csharp/entity-framework/read-only/) intentionally. Configured auto-discovery is supported; do not duplicate it with mandatory manual registration. On the JVM, equal-strength claims for the same type fail startup rather than picking a store by bean order, and a registry-owned read-model parameter is resolved before ordinary Spring services, so a bean of the model class cannot bypass tenant or key selection. ## Write against standalone state This command declaration uses the [MongoDB tutorial’s setup, imports, and author types](/arc/backend/csharp/getting-started/your-first-command/). Add the shown key import to the file: * C# ```csharp using System.ComponentModel.DataAnnotations; [Command] public record RenameAuthor([property: Key] AuthorId Id, AuthorName NewName) { public Task Handle(Author author, IMongoCollection authors) => authors.ReplaceOneAsync(existing => existing.Id == author.Id, author with { Name = NewName }); } ``` * Java ```java import io.cratis.arc.artifacts.CommandKey; @Command public record RenameAuthor(@CommandKey AuthorId id, AuthorName newName) { public void handle(Author author, @FromServices AuthorRepository authors) { authors.save(new Author(author.id(), newName)); } } ``` * Kotlin ```kotlin import io.cratis.arc.artifacts.CommandKey @Command data class RenameAuthor(@CommandKey val id: AuthorId, val newName: AuthorName) { suspend fun handle(author: Author, @FromServices authors: AuthorRepository) { authors.save(author.copy(name = newName)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, commandReadModel, inject, key } from '@cratis/arc.core'; @command() class RenameKnownAuthor { @field(AuthorId) @key() id!: AuthorId; @field(AuthorName) newName!: AuthorName; @inject(commandReadModel(Author), AuthorRepository) async handle(author: Author, authors: AuthorRepository): Promise { author.name = this.newName; await authors.save(author); } } // Configure Author as a MongoDB-owned read model; Arc will look it up by the declared key. ``` Arc loads the author for the command key, then injects it alongside the collection or repository that performs the write. The explicit replacement persists the change. A returned DTO alone would not. ## Choose the decision point | Need | Position | | --- | --- | | Reject with a friendly message before work | `CommandValidator` | | Acquire data using current state | `Provide()` / `provide` | | Compute and perform the write or response | `Handle()` / `handle` | All three positions receive the provider-owned instance in C#. On the JVM the registry resolves it for `provide` and `handle`; a validator that needs current state injects its own repository, as the fragments below show. TypeScript binds `commandReadModel(Type)` to a `provide()` or `handle()` parameter. Its validator does not take a read model as a constructor parameter either; the TypeScript fragments inject an application-owned repository, and a validator can instead read one inside an async rule with `readModelForValidation`. Use current state to decide the write Keep state acquisition in the provider or `Provide()`. On the C# backend, for immediate inline writes, prefer returning a [command operation](/arc/backend/csharp/commands/operations/) from `Handle()` describing the chosen replacement; Arc executes it afterward. The direct replacement above remains supported. Moving execution does not make the acquired state fresh or remove the need for database constraints and atomic conditional writes. For example, a **validator fragment** beside the command can reject a rename to the current name: * C# ```csharp public class RenameAuthorValidator : CommandValidator { public RenameAuthorValidator(Author? author) { RuleFor(command => command.Id) .Must(_ => author is not null) .WithMessage("Author does not exist."); When(_ => author is not null, () => RuleFor(command => command.NewName) .Must(name => name != author!.Name) .WithMessage("Choose a different name.")); } } ``` * Java ```java public final class RenameAuthorValidator implements BlockingCommandValidator { private final AuthorRepository authors; public RenameAuthorValidator(AuthorRepository authors) { this.authors = authors; } @Override public Class getCommandType() { return RenameAuthor.class; } @Override public List validate(RenameAuthor command, CommandContext context) { var author = authors.findById(command.id()); if (author == null) { return List.of(ValidationResult.error("Author does not exist.", List.of("id"))); } return author.name().equals(command.newName()) ? List.of(ValidationResult.error("Choose a different name.", List.of("newName"))) : List.of(); } } @Configuration public class RenameAuthorValidation { @Bean public CommandValidator renameAuthorValidator(AuthorRepository authors) { return new BlockingCommandValidatorAdapter<>(new RenameAuthorValidator(authors)); } } ``` * Kotlin ```kotlin @Component class RenameAuthorValidator( private val authors: AuthorRepository ) : CommandValidator { override val commandType: Class = RenameAuthor::class.java override suspend fun validate( command: RenameAuthor, context: CommandContext ): List { val author = authors.findById(command.id) ?: return listOf(ValidationResult.error("Author does not exist.", listOf("id"))) return if (author.name == command.newName) { listOf(ValidationResult.error("Choose a different name.", listOf("newName"))) } else { emptyList() } } } ``` * TypeScript (preview) ```typescript import { CommandValidator, injectable, validator } from '@cratis/arc.core'; @validator(RenameAuthor) @injectable(AuthorRepository) export class RenameAuthorValidator extends CommandValidator { constructor(authors: AuthorRepository) { super(); this.ruleFor(command => command.id) .mustAsync(async (_id, command, signal) => await authors.findById(command.id, signal) !== undefined) .withMessage('Author does not exist.'); this.ruleFor(command => command.newName) .mustAsync(async (newName, command, signal) => { const author = await authors.findById(command.id, signal); return author === undefined || author.name.value !== newName; }) .withMessage('Choose a different name.'); } } ``` In C# the nullable validator parameter turns absence into a business rejection before the non-nullable handler runs. **Provider-owned resolution reaches the JVM validator differently:** Arc.Kotlin resolves an owned read model for the `handle` and `provide` parameters, not into a `CommandValidator` constructor, so the JVM sibling injects its own repository and looks the author up itself. The TypeScript sibling does the same with an injected application-owned repository. The rule, the message, and the member it is reported against are the same. See [Provide data to a command handler](/arc/scenarios/provide-data-to-a-command/) for combining state with external data. ## Say what absence means * **Nullable:** a valid key with no record supplies `null`; write the business rule around that possibility. TypeScript declares this with `commandReadModel(Type, { optional: true })`. On the JVM a Kotlin `T?` parameter observes `null` and an ordinary Java `Optional` observes `Optional.empty()`. * **Non-nullable:** the instance is required. Missing state produces `ReadModelDoesNotExistForCommand` as a validation failure (HTTP 400) in C#, one `dependencyUnavailable` validation result and HTTP 400 on the JVM, and a validation failure in TypeScript — rather than invoking your code with a fabricated model. * **No usable key:** even a nullable parameter cannot mean “not found” when Arc cannot identify what to fetch. Key-resolution failure is separate from absence. For registration, absence can be the **desired** state. This alternative domain fragment assumes a keyed `RegisterCustomer` and a provider-owned `Customer`: * C# ```csharp public class RegisterCustomerValidator : CommandValidator { public RegisterCustomerValidator(Customer? customer) => RuleFor(_ => customer).Null().WithMessage("Customer is already registered."); } ``` * Java ```java public final class RegisterCustomerValidator implements BlockingCommandValidator { private final CustomerRepository customers; public RegisterCustomerValidator(CustomerRepository customers) { this.customers = customers; } @Override public Class getCommandType() { return RegisterCustomer.class; } @Override public List validate(RegisterCustomer command, CommandContext context) { return customers.findById(command.id()).isEmpty() ? List.of() : List.of(ValidationResult.error("Customer is already registered.", List.of("id"))); } } @Configuration public class RegisterCustomerValidation { @Bean public CommandValidator registerCustomerValidator(CustomerRepository customers) { return new BlockingCommandValidatorAdapter<>(new RegisterCustomerValidator(customers)); } } ``` * Kotlin ```kotlin @Component class RegisterCustomerValidator( private val customers: CustomerRepository ) : CommandValidator { override val commandType: Class = RegisterCustomer::class.java override suspend fun validate( command: RegisterCustomer, context: CommandContext ): List = if (customers.findById(command.id) == null) { emptyList() } else { listOf(ValidationResult.error("Customer is already registered.", listOf("id"))) } } ``` * TypeScript (preview) ```typescript import { CommandValidator, injectable, validator } from '@cratis/arc.core'; @validator(RegisterCustomer) @injectable(CustomerRepository) export class RegisterCustomerValidator extends CommandValidator { constructor(customers: CustomerRepository) { super(); this.ruleFor(command => command.id) .mustAsync(async (_id, command, signal) => await customers.findById(command.id, signal) === undefined) .withMessage('Customer is already registered.'); } } ``` For an operation requiring an existing order, a non-nullable dependency can instead express that precondition. This fragment assumes a keyed `SubmitOrder`, a provider-owned order read model, and an application `OrderStatus` enum: * C# ```csharp public class SubmitOrderValidator : CommandValidator { public SubmitOrderValidator(OrderReadModel order) => RuleFor(_ => order.Status) .Equal(OrderStatus.ReadyForSubmission) .WithMessage("Only orders that are ready for submission can be submitted."); } ``` * Java ```java @Command public record SubmitOrder(@CommandKey UUID id) { public void handle(OrderView order, @FromServices OrderSubmissions orders) { orders.submit(order.id()); } } ``` * Kotlin ```kotlin @Command data class SubmitOrder(@CommandKey val id: UUID) { suspend fun handle(order: OrderView, @FromServices orders: OrderSubmissions) { orders.submit(order.id) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, commandReadModel, inject, key, rejected, validation, type Outcome } from '@cratis/arc.core'; @command() class SubmitReadyOrder { @field(OrderId) @key() id!: OrderId; @inject(commandReadModel(Order)) handle(order: Order): Outcome | void { if (order.status !== OrderStatus.ReadyForSubmission) return rejected(validation('The order is not ready for submission', ['id'])); // Perform the application write here; reading a model does not persist a change. } } // Register Order with its owning read-model integration before running this command. ``` The tabs put the same statement in the position each backend supports. C# declares the requirement on the validator’s non-nullable constructor parameter; the JVM and TypeScript declare it on the handler’s read-model parameter, because their validators do not take read models as constructor parameters. Either way, a missing record rejects the command before your code decides anything. [ARC0006](/arc/backend/csharp/code-analysis/arc0006/) warns on non-nullable read-model dependencies in C# so the choice is explicit. A shared instance prevents duplicate lookups; it is **not a lock, transaction, or freshness guarantee**. Other commands can change the database after it was read. Protect hard invariants with database constraints or an atomic conditional write/transaction. Chronicle projections may additionally lag their events; its [constraints](/chronicle/constraints/) enforce supported invariants at append time. ## Optional: projected state with Chronicle With [Chronicle integration](/arc/backend/csharp/chronicle/), read models can be backed by fluent `IProjectionFor`, model-bound projection attributes, or `IReducerFor`. Integration-specific key resolution can use event-source identifiers. The same injection positions apply. These are **integrated domain fragments**, assuming an event-source `LedgerId` / `AccountId`, configured projections, and registered event types; they do not persist anything in standalone Core: * C# ```csharp [Command] public record SettleLedger(LedgerId LedgerId) { public Result Handle(LedgerBalance? balance) => balance is null ? ValidationResult.Error("The ledger does not exist", [nameof(LedgerId)]) : new LedgerSettled(balance.Balance); } [Command] public record WithdrawFunds(AccountId AccountId, decimal Amount) { public Result Handle(AccountBalance? balance) => balance is null ? ValidationResult.Error("The account does not exist", [nameof(AccountId)]) : new FundsWithdrawn(Amount, balance.Balance - Amount); } ``` * Java ```java @Command public record SettleLedger(@CommandKey LedgerId ledgerId) { public LedgerSettled handle(LedgerBalance balance) { return new LedgerSettled(balance.balance()); } } @Command public record WithdrawFunds(@CommandKey AccountId accountId, BigDecimal amount) { public FundsWithdrawn handle(AccountBalance balance) { return new FundsWithdrawn(amount, balance.balance().subtract(amount)); } } ``` * Kotlin ```kotlin @Command data class SettleLedger(@CommandKey val ledgerId: LedgerId) { fun handle(balance: LedgerBalance): LedgerSettled = LedgerSettled(balance.balance) } @Command data class WithdrawFunds(@CommandKey val accountId: AccountId, val amount: BigDecimal) { fun handle(balance: AccountBalance): FundsWithdrawn = FundsWithdrawn(amount, balance.balance - amount) } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, commandReadModel, inject, key, readModel } from '@cratis/arc.core'; import { eventType } from '@cratis/chronicle/events'; import { fromEvent } from '@cratis/chronicle/projections'; @eventType() class LedgerSettled { @field(Number) balance: number; constructor(balance: number) { this.balance = balance; } } @readModel() @fromEvent(LedgerSettled) class LedgerBalance { @field(String) id = ''; @field(Number) balance = 0; } @command() class SettleLedger { @field(String) @key() id = ''; @inject(commandReadModel(LedgerBalance)) handle(ledger: LedgerBalance): LedgerSettled { return new LedgerSettled(ledger.balance); } } // Call builder.withChronicle(...) before builder.add(SettleLedger, LedgerBalance, LedgerSettled). ``` In C#, the nullable parameter satisfies [ARC0006](/arc/backend/csharp/code-analysis/arc0006/), and missing state rejects the command with a validation error. Do not return a nullable event to signal “nothing to do”: the command then succeeds without recording anything, and the caller cannot tell that it never happened. The event-source identity is stated differently. TypeScript’s experimental Chronicle integration uses the resolved command key as its default event-source ID; declare `@key()` or supply a resolver rather than relying on a property name. C# also accepts an `EventSourceId`-derived property as the identity candidate, so `SettleLedger` needs no attribute; Arc.Kotlin reads `CommandContext.commandKey`, so the identifying property carries `@CommandKey` and a command returning events without a resolvable key is rejected rather than appended to the wrong stream. A `SettleLedgerValidator` can reject a nonpositive balance; an order validator can similarly gate on `ReadyForSubmission`. Those are state checks, not concurrency guarantees. A Chronicle command scenario can pin the read model the command reads. This **setup fragment** supplies that state directly: * C# ```csharp void Establish() => _scenario.Given.ForEventSource(_accountId) .ReadModel(new AccountBalance(150m)); ``` * Java ```java @BeforeEach void establish() { scenario.withReadModelForKey( AccountBalance.class, accountId, new AccountBalance(new BigDecimal("150"))); } ``` * Kotlin ```kotlin @BeforeEach fun establish() { scenario.withReadModelForKey( AccountBalance::class.java, accountId, AccountBalance(BigDecimal("150")) ) } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, commandReadModel, inject, key, readModel } from '@cratis/arc.core'; import { eventType } from '@cratis/chronicle/events'; import { fromEvent } from '@cratis/chronicle/projections'; import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing'; @eventType() class AccountOpened { @field(Number) balance = 0; } @readModel() @fromEvent(AccountOpened) class AccountBalance { @field(String) id = ''; @field(Number) balance = 0; } @command() class CheckAccount { @field(String) @key() id = ''; @inject(commandReadModel(AccountBalance)) handle(balance: AccountBalance): number { return balance.balance; } } const scenario = ChronicleCommandScenario.for(CheckAccount, AccountBalance, AccountOpened); const balance = new AccountBalance(); balance.id = 'account-1'; balance.balance = 25; scenario.givenReadModel(AccountBalance, 'account-1', balance); const result = await scenario.execute({ id: 'account-1' }); result.shouldBeSuccessful(); await scenario.dispose(); ``` C# pins through the Chronicle-specific `Given.ForEventSource(id).ReadModel(...)`. TypeScript pins with `givenReadModel(AccountBalance, id, instance)`; `given.forEventSource(id).readModel(instance)` does the same. Arc.Kotlin pins through the scenario’s own ownership registry with `withReadModelForKey`, which works for Chronicle-, JPA-, or MongoDB-owned read models and also proves the command carries the key it should. Seeding events instead of pinning establishes event-source history, but what it gives the command differs by backend. In C#, seeded events given with `Given.ForEventSource(id).Events(...)` materialize reducer- and projection-backed read models. In TypeScript, the in-memory `ChronicleCommandScenario` materializes reducer-backed read models from seeded events and rejects projection-backed ones with seeded history; test those with `ChronicleKernelScenario`. On the JVM, seeded events populate only the in-memory event log, not the command read-model registry, so pin the read model the command reads. See [Chronicle testing](/arc/backend/csharp/testing/chronicle/) and the [JVM testing guide](/arc/backend/kotlin/guides/testing/) for the complete fixtures. Standalone tests instead seed their provider or register application-service fakes as in [Test a command](/arc/scenarios/test-a-command/). ## See also * [Read models from other providers](/arc/backend/csharp/chronicle/read-models/other-providers/) — provider ownership and explicit standalone keys. * [Resolution failures](/arc/backend/csharp/chronicle/read-models/failures/) — distinguish missing state, missing keys, and configuration errors. * [Validate a command](/arc/scenarios/validate-a-command/) — choose the narrowest place for a rule. * [Chronicle event-source resolution](/arc/backend/csharp/chronicle/resolving-event-source-id/) — optional integration key conventions. * [Identify a command with a key](/arc/backend/kotlin/guides/command-keys/) — `@CommandKey` and `CommandKeyProvider` on the JVM. * [Repositories in queries and commands](/arc/backend/kotlin/guides/spring-data/repositories/) — JVM provider ownership, tenancy, and absence behavior. # Validate a command > Reject malformed or duplicate input before a standalone command writes, using concept rules, command rules, and state-dependent validation. **Goal:** reject bad input before a command changes state. A blank name, a negative quantity, a duplicate email — you want the command rejected, with a clear reason, before `Handle()` runs. ## Validation runs before the handler Arc runs validators *before* it invokes `Handle()`. A command that fails validation never invokes `Handle()` and returns a `CommandResult` carrying the errors. The proxy generator extracts a **supported subset** of rules for client feedback; async, custom, and state-dependent rules still require the authoritative server check. There are three places a rule can live; reach for the narrowest one that fits. ## Do it The validator fragments below assume the [standalone tutorial’s domain types and imports](/arc/backend/csharp/getting-started/your-first-command/). Choose one `RegisterAuthorValidator` example, not multiple competing validators. State/service examples additionally require the provider or application-owned collaborator stated beside them. 1. **A rule that’s true of a value everywhere → validate the value type.** Write a `ConceptValidator` and it applies to every command carrying that concept: * C# ```csharp public class AuthorNameValidator : ConceptValidator { public AuthorNameValidator() => RuleFor(x => x.Value).NotEmpty().WithMessage("An author needs a name."); } ``` * Java ```java // The concept rule is synchronous; its model-pipeline bridge is inherited. // Java publishes it as an ordinary component. @Component public final class AuthorNameValidator implements FundamentalsConceptValidator { @Override public Class getConceptType() { return AuthorName.class; } @Override public List validate(AuthorName concept) { return concept.value().isBlank() ? List.of(ValidationResult.error("An author needs a name.")) : List.of(); } } ``` * Kotlin ```kotlin @Component class AuthorNameValidator : FundamentalsConceptValidator { override val conceptType: Class = AuthorName::class.java override fun validate(concept: AuthorName): List = if (concept.value().isBlank()) { listOf(ValidationResult.error("An author needs a name.")) } else { emptyList() } } ``` * TypeScript (preview) ```typescript import { ConceptValidator, validator } from '@cratis/arc.core'; @validator(AuthorName) export class AuthorNameValidator extends ConceptValidator { constructor() { super(); this.ruleFor(name => name.value).notEmpty().withMessage('An author needs a name.'); } } ``` The C# validator is FluentValidation-based; the Kotlin one implements Arc’s `ConceptValidator` interface and is registered as an ordinary Spring bean. Either way the rule travels with the value, not with one command. 2. **A rule specific to one command → validate the command.** Use a `CommandValidator` for cross-field or command-only rules: * C# ```csharp public class RegisterAuthorValidator : CommandValidator { public RegisterAuthorValidator() => RuleFor(c => c.Name).NotEmpty().MaximumLength(200); } ``` * Java ```java public final class RegisterAuthorValidator implements BlockingCommandValidator { @Override public Class getCommandType() { return RegisterAuthor.class; } @Override public List validate(RegisterAuthor command, CommandContext context) { var name = command.name().value(); if (name.isBlank()) { return List.of(ValidationResult.error("An author needs a name.", List.of("name"))); } if (name.length() > 200) { return List.of(ValidationResult.error( "An author name is at most 200 characters.", List.of("name"))); } return List.of(); } } @Configuration public class RegisterAuthorValidation { @Bean public CommandValidator registerAuthorValidator() { return new BlockingCommandValidatorAdapter<>(new RegisterAuthorValidator()); } } ``` * Kotlin ```kotlin @Component class RegisterAuthorValidator : CommandValidator { override val commandType: Class = RegisterAuthor::class.java override suspend fun validate( command: RegisterAuthor, context: CommandContext ): List = when { command.name.value().isBlank() -> listOf(ValidationResult.error("An author needs a name.", listOf("name"))) command.name.value().length > 200 -> listOf(ValidationResult.error("An author name is at most 200 characters.", listOf("name"))) else -> emptyList() } } ``` * TypeScript (preview) ```typescript import { CommandValidator, validator } from '@cratis/arc.core'; @validator(RegisterAuthor) export class RegisterAuthorValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.name).notEmpty().maxLength(200); } } ``` For lightweight cases, C# data annotations like `[Required]` on the command record work too. On the JVM, `CommandValidator` is an imperative suspending bean rather than a fluent builder; when a **literal** member rule should also run in the generated TypeScript client, declare it with [shared fluent validation](/arc/backend/kotlin/guides/validation/) instead, which is limited to string, collection, and numeric members. 3. **A rule that depends on existing state → use a provider or application service.** For by-key read-model injection, configure [provider ownership and an explicit command key](/arc/scenarios/use-current-state-in-a-command/). Otherwise inject a database context, collection, or application collaborator and query explicitly. A validation pre-check is not atomic, even when moved into `Handle()`. Back uniqueness with a database unique index; use transactions or atomic conditional updates for other invariants. The [tutorial installs the index](/arc/tutorial/validation/) and explains the remaining duplicate-exception translation. [Typed handler failures](/arc/scenarios/return-a-result-or-error/) can express a rejected write without implying that a returned object was persisted. With **optional Chronicle integration**, a registered event return can be appended and a Chronicle [constraint](/chronicle/constraints/) can enforce its supported invariant at append time. Neither behavior belongs to standalone Arc.Core. When a snapshot check is sufficient, place the rule in the validator. If the condition must still hold when the write commits, enforce it through an atomic write, transaction, or appropriate constraint. Order status, account freezes, and role existence can all change after validation; a validator gives early feedback, not a commit-time guarantee: This **domain fragment** assumes a keyed `SubmitOrder`, a provider-owned `OrderReadModel`, and an application `OrderStatus` enum: * C# ```csharp public class SubmitOrderValidator : CommandValidator { public SubmitOrderValidator(OrderReadModel? order) { RuleFor(_ => order).NotNull().WithMessage("Order does not exist."); When(_ => order is not null, () => RuleFor(_ => order!.Status) .Equal(OrderStatus.ReadyForSubmission) .WithMessage("Only orders that are ready for submission can be submitted.")); } } ``` * Java ```java public final class SubmitOrderValidator implements BlockingCommandValidator { private final OrderViewRepository orders; public SubmitOrderValidator(OrderViewRepository orders) { this.orders = orders; } @Override public Class getCommandType() { return SubmitOrder.class; } @Override public List validate(SubmitOrder command, CommandContext context) { var order = orders.findById(command.id()).orElse(null); if (order == null) { return List.of(ValidationResult.error("Order does not exist.", List.of("id"))); } return order.status() == OrderStatus.READY_FOR_SUBMISSION ? List.of() : List.of(ValidationResult.error( "Only orders that are ready for submission can be submitted.", List.of("id"))); } } @Configuration public class SubmitOrderValidation { @Bean public CommandValidator submitOrderValidator(OrderViewRepository orders) { return new BlockingCommandValidatorAdapter<>(new SubmitOrderValidator(orders)); } } ``` * Kotlin ```kotlin @Component class SubmitOrderValidator( private val orders: OrderViewRepository ) : CommandValidator { override val commandType: Class = SubmitOrder::class.java override suspend fun validate( command: SubmitOrder, context: CommandContext ): List { val order = orders.findById(command.id).orElse(null) ?: return listOf(ValidationResult.error("Order does not exist.", listOf("id"))) return if (order.status == OrderStatus.READY_FOR_SUBMISSION) { emptyList() } else { listOf( ValidationResult.error( "Only orders that are ready for submission can be submitted.", listOf("id") ) ) } } } ``` * TypeScript (preview) ```typescript import { CommandValidator, injectable, validator } from '@cratis/arc.core'; @validator(SubmitOrder) @injectable(OrderRepository) export class SubmitOrderValidator extends CommandValidator { constructor(orders: OrderRepository) { super(); this.ruleFor(command => command.id) .mustAsync(async (_id, command, signal) => await orders.findById(command.id, signal) !== undefined) .withMessage('Order does not exist.'); this.ruleFor(command => command.id) .mustAsync(async (_id, command, signal) => { const order = await orders.findById(command.id, signal); return order === undefined || order.status === OrderStatus.ReadyForSubmission; }) .withMessage('Only orders that are ready for submission can be submitted.'); } } ``` The backends differ in *where* the current state comes from. C# resolves a provider-owned read model straight into the validator’s constructor, and the nullable parameter is how you say a missing record is a business condition rather than a fault — [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) covers that choice and all three positions in full. On the JVM the owned read model is resolved for the **handler** (`fun handle(order: OrderView?)`), so a validator loads it through its own injected repository. TypeScript can inject an application repository into a validator; an async rule can also use `readModelForValidation` for a command-keyed model. A validator can also reach *outside* the command’s own state. It’s resolved through dependency injection, so it can take a collaborator and consult it. `IAuthorsCatalog` / `AuthorsCatalog` is an application-owned contract, not an Arc interface. Its implementation must be available through DI: * C# ```csharp public interface IAuthorsCatalog { Task IsRegistered(AuthorName name); } public class RegisterAuthorValidator : CommandValidator { public RegisterAuthorValidator(IAuthorsCatalog authors) { RuleFor(c => c.Name).NotEmpty().MaximumLength(200); RuleFor(c => c) .MustAsync(async (command, ct) => !await authors.IsRegistered(command.Name)) .WithMessage("An author with that name is already registered."); } } ``` * Java ```java public interface AuthorsCatalog { CompletionStage isRegistered(AuthorName name); } // AsyncCommandValidator returns a CompletionStage, so the lookup composes without // blocking - there is no fluent async-predicate rule to reach for. public final class RegisterAuthorValidator implements AsyncCommandValidator { private final AuthorsCatalog authors; public RegisterAuthorValidator(AuthorsCatalog authors) { this.authors = authors; } @Override public Class getCommandType() { return RegisterAuthor.class; } @Override public CompletionStage> validate( RegisterAuthor command, CommandContext context ) { return authors.isRegistered(command.name()).thenApply(registered -> registered ? List.of(ValidationResult.error( "An author with that name is already registered.", List.of("name"))) : List.of()); } } @Configuration public class RegisterAuthorValidation { @Bean public CommandValidator registerAuthorValidator(AuthorsCatalog authors) { return new AsyncCommandValidatorAdapter<>(new RegisterAuthorValidator(authors)); } } ``` * Kotlin ```kotlin interface AuthorsCatalog { suspend fun isRegistered(name: AuthorName): Boolean } @Component class RegisterAuthorValidator( private val authors: AuthorsCatalog ) : CommandValidator { override val commandType: Class = RegisterAuthor::class.java // validate is a suspending function, so the lookup is awaited directly - // there is no fluent async-predicate rule to reach for. override suspend fun validate( command: RegisterAuthor, context: CommandContext ): List = if (authors.isRegistered(command.name)) { listOf( ValidationResult.error( "An author with that name is already registered.", listOf("name") ) ) } else { emptyList() } } ``` * TypeScript (preview) ```typescript import { CommandValidator, injectable, validator } from '@cratis/arc.core'; // An abstract class, not an interface: the class itself is the service token. export abstract class AuthorsCatalog { abstract isRegistered(name: AuthorName, signal: AbortSignal): Promise; } @validator(RegisterAuthor) @injectable(AuthorsCatalog) export class RegisterAuthorValidator extends CommandValidator { constructor(authors: AuthorsCatalog) { super(); this.ruleFor(command => command.name).notEmpty().maxLength(200); this.ruleFor(command => command.name) .mustAsync(async (_name, command, signal) => !await authors.isRegistered(command.name, signal)) .withMessage('An author with that name is already registered.'); } } ``` A rule like this runs on the server only — unlike the declarative rules, it can’t be extracted into the generated proxy. The shapes genuinely diverge here: C# expresses it as FluentValidation’s `MustAsync` predicate, while Arc.Kotlin’s shared fluent surface is deliberately literal and has **no** dynamic or asynchronous predicate rule. The JVM sibling is therefore the ordinary imperative `CommandValidator`, whose `validate` is already a suspending function. Validators are discovered by convention — you never register them. The frontend surfaces the messages automatically; see [Execute a command from React](/arc/scenarios/run-a-command-from-react/). ## See also * [Command Validation](/arc/backend/csharp/commands/command-validation/) and [Validation](/arc/backend/csharp/commands/validation/) — the full validation model. * [Make it trustworthy](/arc/tutorial/validation/) — the same ideas, taught step by step. * [Return a result or an error](/arc/scenarios/return-a-result-or-error/) — standalone responses and the `Result<,>` return shape. * [Use current state in a command](/arc/scenarios/use-current-state-in-a-command/) — injecting provider-owned state into a validator, `Provide()`, or `Handle()`. # Troubleshooting > Fixes for the issues that come up most when building with Arc — proxies, authorization, validation, and stale types. Most Arc snags come down to a handful of causes. Here they are. If the slice uses the Chronicle integration, see [Chronicle troubleshooting](/chronicle/troubleshooting/) for event-store-specific issues. Using another backend? The fixes below are for C#. See [JVM troubleshooting](/arc/backend/kotlin/troubleshooting/) for Gradle, KSP, and Spring Boot, or [TypeScript proxy generation](/arc/backend/typescript/proxy-generation/getting-started/) and the [TypeScript capability reference](/arc/backend/typescript/reference/capabilities/) for the source preview. Do not apply the .NET build or discovery steps to those hosts. ## My frontend can’t find the generated proxy For **C#**, proxies generate on a configured Debug backend build. If the import doesn’t resolve: * Install `Cratis.Arc.ProxyGenerator.Build`, set `CratisProxiesOutputPath`, and run `dotnet build -c Debug` on the backend and confirm it succeeds — no proxies are emitted until the C# compiles. * Check the command/query is discoverable: a `[Command]` record with a `Handle()` method, or a static query method on a `[ReadModel]`. * Make sure proxy generation is targeting the right output folder for your frontend (see [Proxy Generation](/arc/backend/csharp/proxy-generation/)). ## I changed the C# but the TypeScript is stale With C# generation configured, proxies regenerate on a Debug build. Rebuild the backend; the frontend types update with it. If a renamed property doesn’t surface, you’ve found the feature working — the old name should now fail to compile until you update the call site. ## My command’s OK/Submit button stays disabled The command isn’t considered valid. The usual cause: a required value is being set in `onBeforeExecute` instead of `initialValues`. `onBeforeExecute` runs at execution time — too late to affect validity — so the form never becomes valid. Put **all required values**, including parent ids and newly generated Guids, in `initialValues`. Generate a stable id when each dialog operation starts; do not reuse it for the next operation. Reserve `onBeforeExecute` for transformations that do not determine validity. See [Building a form](/components/building-a-form/). ## My command returns 401 or 403 That’s authorization. Check that: * the caller is authenticated, and * the caller has the role the command requires (`[Roles(...)]` on the command — or on the query method for a 403 on reads). For local development you can generate a principal so you can exercise authorized endpoints without a full login — see [Identity](/arc/backend/csharp/identity/) and [Authorizing commands and queries](/arc/backend/csharp/authorizing-commands-and-queries/). ## My validation isn’t firing Arc discovers a `CommandValidator` by convention. Confirm the validator’s generic type matches the command exactly, and that any async rule that needs a dependency takes it via the validator’s constructor. See [Commands](/arc/backend/csharp/commands/). ## My query returns nothing This is usually the read side, not proxy generation: * Confirm the command wrote the document or entity you expect. * Check any query predicate, especially ids passed through `[Key]`. * If the query is observable, confirm the underlying store is configured for observation: MongoDB change streams or EF observed DbSets. If the slice is backed by Chronicle, also check that the projection has caught up and maps the events you appended. ## See also * [Backend](/arc/backend/) and [Frontend](/arc/frontend/) guides. * [Chronicle troubleshooting](/chronicle/troubleshooting/) for optional event-store issues. # Tutorial: Build a library, full-stack > Build a small but real library app with Arc — typed commands and queries, validation, real-time React screens, and authorization — one chapter at a time. Let’s build something real together: the back office for a small library. Librarians register authors, catalog the books each author wrote, and watch the catalog fill in live as they work. It’s a modest app — but by the time it’s done you’ll have used every part of Arc that a real full-stack feature needs, and you’ll understand *why* each part is shaped the way it is. This isn’t a tour of one slice in isolation. We’ll build several features that lean on each other, the way a real app does — and at each step we’ll stop, look at what just happened, and only then move on. The thing Arc is really selling is that the whole loop — a backend command, the read model it updates, the query that serves it, and the React screen that calls both — stays **type-safe end to end**, with no hand-written API client in the middle. You’ll feel that pay off repeatedly. Arc doesn’t care where your data lives. We’ll store it straight in a database — MongoDB or EF Core on C#, Spring Data on the JVM, or MongoDB in the TypeScript source preview — so this tutorial can focus on CQRS and the generated proxy boundary. That is a teaching and adoption choice, not a claim that event sourcing is secondary: for information systems, we usually recommend Chronicle as the event-sourced backbone. The point is that CQRS can run with or without it. Here’s the shape of what we’re heading toward, as an **[event model](/event-modeling/)** — the way you’d whiteboard a feature *before* deciding how to store it. Read it left to right: the librarian adds an author on a screen, the `RegisterAuthor` command records the business fact that an author was registered, the `Author` read model is updated, and the next screen lists it. Don’t worry if the pieces aren’t familiar yet — we’ll meet each one in turn. ``` UI/A: AuthorsUI/A: AuthorsC/RM: AuthorsC/RM: AuthorsStream: Authors AddAuthor RegisterAuthor id: uuid, name: string AuthorRegistered name: string Author Authors ``` `AuthorRegistered` is the business **fact** in the model; it is not a storage decision. Over a plain database your command writes the `Author` read model directly. Each later feature (add books, list them) is another column of the same shape. That’s *what* you’ll build. Here’s *how* Arc runs it — and the part that earns its place: you declare the command and query on your backend, then **generate typed proxies** your React calls, so a frontend type-check can catch structural drift. Over a database the path is direct: ``` Arc (C#) React (TypeScript) generated command proxy writes generated query proxy, live Library screen commands — RegisterAuthor · AddBook read models — Author · BookOnShelf queries MongoDB / EF Core ``` The two diagrams are two views of the same feature: the event model is the **domain flow** you design, and the C# flowchart is one example of the **typed boundary** — generated backend → TypeScript proxies, no hand-written API client. That boundary is the heart of Arc; [Understanding the proxy boundary](/arc/understanding-the-proxy-boundary/) goes deeper on it. ## What you’ll build By the last chapter you’ll have a working library back office where a librarian can: * **register an author** and see the list update the instant they confirm — no refresh, * be **stopped from registering a blank or duplicate name**, with the reason shown right on the form, * **add books** to an author and browse them, * and do all of this behind **role-based authorization**, so only a librarian can change the catalog. ## What you’ll learn * The full Arc loop — **command → read model → query → React** — and how generation (`dotnet build -c Debug`, `./gradlew generateArcProxies`, or the TypeScript application’s `npm run generate`) followed by a frontend type-check catches drift. * How to put **validation and business rules** on a command and have the failure surface in the UI through the generated proxy. * How to model a **second feature that reads the first**, and read related data back. * How **observable queries** keep a screen live with no polling. * How to **authorize** commands and queries at the boundary. ## One tutorial, three backend paths There is **one tutorial** with C#, JVM, and TypeScript backend examples. The TypeScript backend is a [source preview](/arc/backend/typescript/) without a published npm package or full parity. Use its own setup and [capability reference](/arc/backend/typescript/reference/capabilities/) before following a chapter’s tab. The React examples consume the shared frontend runtime rather than three different browser libraries. The ideas are the same across these paths — a slice from command to read model to query to screen, validation, relating slices, live queries, authorization. What is **not** the same is the scaffolding around that code: the SDK, the generation command, the database integration, and the way a development user signs in. Each chapter names setup and behavior differences where they matter. Building on Kotlin or Java? Set up first with [Get started with Kotlin](/arc/backend/kotlin/get-started/) or [Get started with Java](/arc/backend/kotlin/get-started/java/), which take you through the first round trip with Gradle and Spring Boot, then follow the chapters below and pick the Kotlin tab. Read “MongoDB or EF Core” as [Spring Data MongoDB or Spring Data JPA](/arc/backend/kotlin/guides/spring-data/), and keep the [Kotlin and Java documentation](/arc/backend/kotlin/) open for the detail a tutorial cannot carry. The [scenario recipes](/arc/scenarios/) show both languages side by side too. ## What you’ll need For C#, start with [standalone ASP.NET Core setup](/arc/backend/csharp/getting-started/), then complete its [backend checkpoint](/arc/backend/csharp/getting-started/your-first-command/). It specifies the SDK, packages, imports, MongoDB replica-set settings or EF Core/SQLite schema bootstrap, and Debug proxy generation. On the JVM, [Get started with Kotlin](/arc/backend/kotlin/get-started/) or [Get started with Java](/arc/backend/kotlin/get-started/java/) plays that part: JDK 17, Gradle, the `io.cratis.arc` plugin, `arc-spring-boot-starter`, and a Spring Data integration. On TypeScript, follow [create an application](/arc/backend/typescript/getting-started/create-an-application/) and use the [standalone MongoDB host](/arc/arc-without-event-sourcing/#a-standalone-slice-end-to-end) as the chapter-1 prerequisite. Choose one database branch and keep it throughout the tutorial. Chapter 1 connects those existing types to the [standalone React setup](/arc/frontend/react/getting-started/); do not recreate them. No Chronicle package, event store, or `dotnet new cratis` scaffold is required. That template is an **Arc + Chronicle** alternative, not this tutorial’s starting point. Backend excerpts assume the setup’s imports and namespaces; UI fragments are labeled where they require the surrounding composition. ## The tour 1. **[Your first full-stack slice](/arc/tutorial/first-slice/)** — register an author from the backend all the way to a live React screen, fully typed. 2. **[Make it trustworthy](/arc/tutorial/validation/)** — reject bad input with a validator and a uniqueness rule, and show the reason in the form. 3. **[Relate your slices](/arc/tutorial/books-and-relationships/)** — add books that belong to an author, and read them back. 4. **[Make it live](/arc/tutorial/real-time/)** — observable queries that update the screen the moment the data changes. 5. **[Decide who can do what](/arc/tutorial/authorization/)** — lock the catalog down with role-based authorization. Each chapter ends where the next begins. By the end you’ll have a real full-stack feature — and the model to build your own. Ready? [Let’s build the first slice →](/arc/tutorial/first-slice/) # 5. Decide who can do what > Gate the catalog with role-based authorization on commands and queries, connect it to the signed-in user, and look back at the full-stack feature you built. Our back office works, but it’s wide open: anyone who can reach it can register authors and add books. The last thing a real app needs is to decide *who is allowed*. We’ll add role-based authorization — and the nice part is that it’s declarative, so none of our `Handle()` methods or read models change. The rule lives at the boundary, where it belongs. ## Prerequisites Continue with the standalone host from [chapter 1](/arc/tutorial/first-slice/); for TypeScript use the [standalone MongoDB host](/arc/arc-without-event-sourcing/#a-standalone-slice-end-to-end), retaining its tenant resolver when adding the development header handler. Add `global using Cratis.Arc.Authorization;` to `GlobalUsings.cs` on C#; on the JVM, import `io.cratis.arc.authorization.Roles` in the files that use it. The TypeScript snippets import `roles` from `@cratis/arc.core`. This chapter uses the role name `Librarian`; it must match an authorization role on the authenticated principal. No identity-details provider is required. On C#, the first excerpt below also declares a one-member `LibraryRole` enum; put it in its own file, `src/LibraryRole.cs`, under `namespace Library;` so every feature folder can use it. The C# excerpts pass `nameof(LibraryRole.Librarian)`, which is still the string `"Librarian"`. Arc’s [ARC0011 analyzer](/arc/backend/csharp/code-analysis/#arc0011-role-names) warns about string literals in `[Roles]`; with `nameof`, a mistyped role name is a compile error instead of a silent denial. The JVM and TypeScript snippets pass the same name as a string. ## Gate the commands Authorization is a cross-cutting concern. You don’t want `if (user.IsLibrarian)` checks scattered through your logic — you want one declaration at the edge. Put `[Roles(...)]` — `@Roles(...)` on the JVM — on the command (`@roles(...)` on TypeScript), and Arc checks the caller’s roles before the command runs. The excerpts below show the document-store branch; on the relational branch retain your existing handlers and queries while replacing the JVM’s `@AllowAnonymous` on the command with `@Roles("Librarian")`. Do not keep both on the same declaration: 1. **Require a role to change the catalog.** Only a librarian may register authors or add books. The TypeScript prerequisite host also exposes `RenameAuthor`, so its tab protects that command too: * C# ```csharp public enum LibraryRole { Librarian } [Command] [Roles(nameof(LibraryRole.Librarian))] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } ``` * Java ```java @Command @Roles("Librarian") public record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); } } ``` * Kotlin ```kotlin @Command @Roles("Librarian") data class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, commandReadModel, inject, key, roles } from '@cratis/arc.core'; @command() @roles('Librarian') export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @inject(AuthorRepository) handle(authors: AuthorRepository): Promise { return authors.save({ id: this.id, name: this.name }); } } @command() @roles('Librarian') export class RenameAuthor { @field(AuthorId) @key() id!: AuthorId; @field(AuthorName) newName!: AuthorName; @inject(commandReadModel(Author), AuthorRepository) async handle(author: Author, authors: AuthorRepository): Promise { author.name = this.newName; await authors.save(author); } } ``` The same declaration goes on `AddBook`; on the JVM, replace its `@AllowAnonymous` annotation rather than stacking the two. The .NET and JVM chapter-1 projects do not include `RenameAuthor`; if you added it from the standalone-slice example, apply the same role to it (replacing `@AllowAnonymous` on the JVM). Everything inside the handler stays exactly as it was — the gate is applied around it. ## Gate the queries The read side deserves the same protection — a public catalog might be fine, but an internal audit list is not. Query methods take the same declaration. Apply it to **both `AllAuthors` and `BooksForAuthor`** to keep this tutorial’s catalog private. On the JVM, remove the chapter-1 `@AllowAnonymous` class annotation from `Author` and the chapter-3 one from `Book`, then add `@Roles("Librarian")` to each query operation. Otherwise a query on the same read model without its own role declaration would remain public: * C# ```csharp [ReadModel] public record Author(AuthorId Id, AuthorName Name) { [Roles(nameof(LibraryRole.Librarian))] public static ISubject> AllAuthors(IMongoCollection authors) => authors.Observe(); } ``` * Java ```java @ReadModel public record Author(AuthorId id, AuthorName name) { @Roles("Librarian") public static Flow.Publisher> allAuthors(@FromServices MongoObservableQuery queries) { return queries.observePublisher(Author.class); } } ``` * Kotlin ```kotlin @ReadModel data class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic @Roles("Librarian") fun allAuthors(@FromServices queries: MongoObservableQuery): Flow> = queries.observe() } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel, roles, service, type ObservableSource } from '@cratis/arc.core'; @readModel() export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @roles('Librarian') @query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource { return authors.observeAll(); } } ``` On all three backends the operation’s own declaration wins over the class’s. That is what lets a protected read model still expose one open query — on the JVM an operation that declares `@AllowAnonymous`, `@Authorize`, or `@Roles` replaces the class default entirely rather than merging with it, so narrowing is exact. See [authorization on artifacts and operations](/arc/backend/kotlin/guides/security/). The frontend respects it too The server enforces these rules for calls from generated proxies and other clients alike; proxies surface the authorization result. So you can drive your UI off identity as well: hide the “Add author” button when the signed-in user isn’t a librarian, and the command is *still* protected on the server if someone calls it anyway. See [frontend identity](/arc/frontend/react/identity/) for reading the current user’s roles in React. ## Where the roles come from Arc checks `ClaimsPrincipal.IsInRole("Librarian")`. Configure your authentication scheme’s role claims accordingly. An application detail such as `LibraryIdentity.Role` does **not** grant that role: `IProvideIdentityDetails` enriches client/domain context, and its rejected result controls `/.cratis/me`, not every operation. The JVM asks the same question of an `ArcPrincipal`: a caller holds `Librarian` when one of its roles is that name, which is what a Spring Security `ROLE_Librarian` authority becomes once Arc drops the prefix. On TypeScript, the `microsoftIdentityPlatform()` development handler reads `Librarian` from the header principal’s `userRoles`. An `IdentityDetailsProvider` plays the same enriching, non-granting role there as `IProvideIdentityDetails` does here. For this **loopback-only development checkpoint**, select the trusted-header adapter for your backend before building the host. The TypeScript builder registers its header handler directly alongside the MongoDB host’s tenant resolver; the JVM bridge needs an explicitly verified trusted ingress, not just a property toggle: * C# ```csharp if (builder.Environment.IsDevelopment()) { builder.Services.AddMicrosoftIdentityPlatformIdentityAuthentication(); builder.Services.Configure(options => options.TrustForwardedIdentityHeaders = true); } ``` * Java ```text Java does not support this workflow yet: the ASP.NET Core adapter is .NET-only. On the JVM, Arc.Kotlin's optional platform identity bridge reads the same x-ms-client-principal headers, but requires cratis.arc.platform-identity.enabled=true and an ArcPlatformIdentityTrust bean that verifies your ingress strips caller-supplied headers. There is no safe generic trust bean to copy: see /arc/backend/kotlin/guides/security/. For another local identity mechanism, register io.cratis.arc.authentication.AsyncAuthenticationHandler. ``` * Kotlin ```text Kotlin does not support this workflow yet: the ASP.NET Core adapter is .NET-only. On the JVM, Arc.Kotlin's optional platform identity bridge reads the same x-ms-client-principal headers, but requires cratis.arc.platform-identity.enabled=true and an ArcPlatformIdentityTrust bean that verifies your ingress strips caller-supplied headers. There is no safe generic trust bean to copy: see /arc/backend/kotlin/guides/security/. For another local identity mechanism, register io.cratis.arc.authentication.AuthenticationHandler. ``` * TypeScript (preview) ```typescript import { ArcApplication, microsoftIdentityPlatform } from '@cratis/arc.core'; const development = process.env.NODE_ENV === 'development'; const builder = ArcApplication.createBuilder({ tenancy: { resolve: () => 'default' }, development, authentication: development ? [microsoftIdentityPlatform()] : [] }); ``` On .NET, before `app.UseCratisArc()`, add the authentication middleware. The TypeScript builder’s `authentication` option already covers this step: * C# ```csharp if (app.Environment.IsDevelopment()) { app.UseAuthentication(); } ``` * Java ```text Java does not support this workflow yet: ASP.NET Core's UseAuthentication is .NET-only. Spring Boot's security chain and Arc.Kotlin's io.cratis.arc.authentication.AsyncAuthenticationHandler beans handle authentication on the JVM; see /arc/backend/kotlin/guides/security/ for the platform filter-chain and handler setup. ``` * Kotlin ```text Kotlin does not support this workflow yet: ASP.NET Core's UseAuthentication is .NET-only. Spring Boot's security chain and Arc.Kotlin's io.cratis.arc.authentication.AuthenticationHandler beans handle authentication on the JVM; see /arc/backend/kotlin/guides/security/ for the platform filter-chain and handler setup. ``` * TypeScript (preview) ```text TypeScript does not support this workflow yet: ASP.NET Core's separate UseAuthentication middleware step is .NET-only. The Node host's ArcApplication.createBuilder authentication option above already registers microsoftIdentityPlatform() for local development; see /arc/backend/typescript/identity/local-development/ for its trust boundary. ``` Keep the host bound to loopback for this development checkpoint: `http://localhost:5000` on C#, or `http://127.0.0.1:3000` on TypeScript. Spring Boot listens on all interfaces by default; JVM readers must set `server.address=127.0.0.1` in local configuration before using `http://127.0.0.1:8080`. A port number alone does not restrict who can reach the server. Follow [Generate a local principal](/arc/backend/csharp/development/generating-principal/) on C# or [Simulate a signed-in user locally](/arc/backend/typescript/identity/local-development/) on TypeScript to send the three `x-ms-client-principal` headers through the local Vite proxy. In the principal JSON, include `"Librarian"` in `userRoles`, then Base64-encode it locally. Restrict the browser header override to your localhost app URLs. On the JVM, use an authenticated `ArcPrincipal` carrying that role through your own handler or verified ingress (see below). This adapter **does not validate bearer-token signatures, issuer, or audience**. It trusts the headers. Production must instead use a properly configured authentication scheme or an authenticated trusted ingress that replaces spoofable headers and prevents direct backend access. Never expose this local header-spoofing fixture publicly. Signing in on the JVM Do not copy the .NET middleware into Spring Boot. A JVM host authenticates through Arc.Kotlin’s ordered `io.cratis.arc.authentication.AuthenticationHandler` beans (or `AsyncAuthenticationHandler` from Java), each returning anonymous, failed, or an `ArcPrincipal` carrying the caller’s roles. The JVM does ship a platform-identity bridge that reads the same `x-ms-client-principal` headers, but it is **off by default**, requires `cratis.arc.platform-identity.enabled=true`, and refuses every submission until you supply an `ArcPlatformIdentityTrust` bean that verifies your ingress strips caller-supplied headers. Start with [Authenticate and authorize Arc endpoints](/arc/backend/kotlin/guides/security/) and register a handler of your own. ## Verify permissions, not just UI visibility Rebuild and restart the C# checkpoint with `-e ASPNETCORE_ENVIRONMENT=Development`, or start the TypeScript host with `NODE_ENV=development` on port 3000, then test these separately. The header adapters run only in development: in Production they are not registered and even the `Librarian` header case is rejected. The checks are the same on all three backends; only the way you present a principal differs — the headers for the .NET and TypeScript fixtures, or whatever your JVM authentication handler accepts: 1. Without identity headers, protected commands and queries must reject access. 2. With a valid local principal but no `Librarian` role, they must still reject access. 3. With `Librarian` in `userRoles`, list authors and register one successfully. 4. Remove the role again. Hiding a button is optional UI behavior; a direct operation call must still fail. Inspect `isAuthorized` on the command/query result. Authentication middleware may reject a request before an Arc result is created, so also inspect the HTTP response. The recap below assumes you observed all three principal cases, not merely added attributes. For production authentication and domain details, continue with [Identity](/arc/backend/csharp/identity/) and [Authorizing commands and queries](/arc/backend/csharp/authorizing-commands-and-queries/), the JVM [security guide](/arc/backend/kotlin/guides/security/), or TypeScript’s [authentication guide](/arc/backend/typescript/core/authentication/). ## Look back at what you built Five chapters ago we had nothing. Now we have a real full-stack library back office, running over a plain database — and you’ve used every part of the Arc loop and know *why* each one is shaped the way it is: * **Chapter 1** — a full-stack slice: a `[Command]`/`@Command` with a handler that writes, a `[ReadModel]`/`@ReadModel` with its query, and a React screen calling both through generated, typed proxies — the same TypeScript whichever backend generated it. * **Chapter 2** — trust: a `ConceptValidator` on the value type and a `CommandValidator` business rule, both surfaced in the UI through the generated proxy. * **Chapter 3** — relationships: a second slice and a filtered query that reads one author’s catalog, no join. * **Chapter 4** — life: observable queries as standing subscriptions, database to browser. * **Chapter 5** — authorization: `[Roles(...)]` at the boundary, on both commands and queries (with `@Roles` on the JVM and `@roles` on TypeScript), tied to the signed-in user. The thing worth taking with you is the loop itself: **something happens (a command) → it writes state → that state is served (a query) → and React calls all of it, typed end to end.** Every feature you build from here is another turn of that loop in its own folder. You never go back and edit the last one to add the next. And notice the boundary you proved: Arc did not need an event store for this CQRS loop. That is not an argument against event sourcing. For information systems, we usually reach for Chronicle as the default because history pays for itself. The point is architectural independence: CQRS can run over a plain database, and event sourcing can run without Arc, but they fit together extremely well. From here, build another feature the same way, or take the next layer up: [Components](/components/) turns these generated commands and queries into higher-level screens, and the [Cratis Stack tour](/cratis-stack/) shows where Arc fits with the rest of the platform. # 3. Relate your slices > Add a second feature — books that belong to an author — and read each author's catalog back with a filtered, live query. An author exists to have written things. So our second feature is **adding a book to an author’s catalog** — and it’s the first time two slices in our app relate to each other. A book belongs to an author; we’ll model that with a plain foreign key — the author’s id on the book — and read a catalog back with a **filtered** query. Here’s the shape: ``` writes live AddBook (keyed by author) Books BooksForAuthor (filtered by AuthorId) React: author → books ``` ## The book slice Continue from [chapter 2](/arc/tutorial/validation/), using its project and one database branch. Create `BookId.cs`, `BookTitle.cs`, `AddBook.cs`, and `Book.cs` under `src/Books/`, each with `namespace Library.Books;` above the declarations shown. Add `global using Library.Books;` to `GlobalUsings.cs`. Existing global imports supply the Arc, provider, and author types. On the JVM, the same four declarations go in a `books` package beside the `authors` one, each file importing what it uses — there is no global-import step. Keep the package inside the application’s component-scan root so Spring finds the repository and Arc’s KSP processor sees the artifacts. 1. **Strong types for the book.** Same discipline as before — a record deriving from `ConceptAs` on C#, a data class implementing `ConceptAs` on the JVM: * C# ```csharp public record BookId(Guid Value) : ConceptAs(Value) { public static readonly BookId NotSet = new(Guid.Empty); public static BookId New() => new(Guid.NewGuid()); } public record BookTitle(string Value) : ConceptAs(Value) { public static readonly BookTitle NotSet = new(string.Empty); public static implicit operator BookTitle(string value) => new(value); } ``` * Java ```java // A record component named value satisfies ConceptAs without any extra code. public record BookId(UUID value) implements ConceptAs { public static BookId newId() { return new BookId(UUID.randomUUID()); } } public record BookTitle(String value) implements ConceptAs { } ``` * Kotlin ```kotlin data class BookId(private val rawValue: UUID) : ConceptAs { override fun value(): UUID = rawValue companion object { fun new(): BookId = BookId(UUID.randomUUID()) } } data class BookTitle(private val rawValue: String) : ConceptAs { override fun value(): String = rawValue } ``` * TypeScript (preview) ```typescript import { ConceptAs, Guid } from '@cratis/fundamentals'; export class BookId extends ConceptAs { static readonly valueType = Guid; static create(): BookId { return new BookId(Guid.create()); } } export class BookTitle extends ConceptAs { static readonly valueType = String; } ``` The C# sentinels and the implicit conversion are conveniences, not requirements; the JVM has no implicit-conversion operator, so a Kotlin caller writes `BookTitle("...")` where C# can pass a bare string. 2. **The command writes a book, tagged with its author.** `AddBook` carries the `AuthorId` it belongs to — and writes a `Book` carrying that id: * C# ```csharp [Command] public record AddBook(AuthorId AuthorId, BookId BookId, BookTitle Title) { public Task Handle(IMongoCollection books) => books.InsertOneAsync(new Book(BookId, AuthorId, Title)); } ``` * Java ```java @Command @AllowAnonymous public record AddBook(AuthorId authorId, BookId bookId, BookTitle title) { public void handle(@FromServices BookRepository books) { books.save(new Book(bookId, authorId, title)); } } ``` * Kotlin ```kotlin @Command @AllowAnonymous data class AddBook(val authorId: AuthorId, val bookId: BookId, val title: BookTitle) { suspend fun handle(@FromServices books: BookRepository) { books.save(Book(bookId, authorId, title)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; @command() export class AddBook { @field(AuthorId) authorId!: AuthorId; @field(BookId) bookId!: BookId; @field(BookTitle) title!: BookTitle; @inject(BookRepository) handle(books: BookRepository): Promise { return books.save({ id: this.bookId, authorId: this.authorId, title: this.title }); } } ``` **On the relational branch**, .NET takes the context and saves through it. The JVM and TypeScript handlers still use a repository; its implementation supplies the relational write: * C# ```csharp [Command] public record AddBook(AuthorId AuthorId, BookId BookId, BookTitle Title) { public async Task Handle(LibraryDbContext db) { db.Books.Add(new Book(BookId, AuthorId, Title)); await db.SaveChangesAsync(); } } ``` * Java ```java @Command @AllowAnonymous public record AddBook(AuthorId authorId, BookId bookId, BookTitle title) { public void handle(@FromServices BookRepository books) { books.save(new Book(bookId, authorId, title)); } } ``` * Kotlin ```kotlin @Command @AllowAnonymous data class AddBook(val authorId: AuthorId, val bookId: BookId, val title: BookTitle) { suspend fun handle(@FromServices books: BookRepository) { books.save(Book(bookId, authorId, title)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; @command() export class AddBook { @field(AuthorId) authorId!: AuthorId; @field(BookId) bookId!: BookId; @field(BookTitle) title!: BookTitle; @inject(BookRepository) handle(books: BookRepository): Promise { return books.save({ id: this.bookId, authorId: this.authorId, title: this.title }); } } ``` On the JVM, the command still uses `BookRepository`: its document-store implementation can use `MongoRepository`, while its relational implementation delegates to `JpaRepository` and converts between `Book` and the mapped row. On TypeScript, create `Features/Books/BookId.ts` and `BookTitle.ts` from the concepts above, `Features/Books/Book.ts` from the query below, and `Features/Books/AddBook.ts` from the command above. Import each type the excerpts use from its feature file, with `.js` on relative backend imports. Put this runtime token in `Features/Books/BookRepository.ts`; both the command and query import it (not as a type-only import): * TypeScript (preview) ```typescript import type { ObservableSource } from '@cratis/arc.core'; // Features/Books/BookRepository.ts — import AuthorId and Book from their feature files. export abstract class BookRepository { abstract save(book: Book): Promise; abstract observeForAuthor(authorId: AuthorId): ObservableSource; } ``` The MongoDB implementation belongs beside it in `Features/Books/MongoBookRepository.ts`. It encodes books through the collection’s codec before writing; the filter uses the codec’s stored field name and the encoded author id so it matches MongoDB’s UUID representation, not a JavaScript `Guid` object: * TypeScript (preview) ```typescript import type { ObservableSource } from '@cratis/arc.core'; import type { MongoCollection } from '@cratis/arc.mongodb'; import type { Document, Filter } from 'mongodb'; // Features/Books/MongoBookRepository.ts — import AuthorId, Book, and BookRepository from their feature files. export class MongoBookRepository extends BookRepository { constructor(private readonly collection: MongoCollection) { super(); } async save(book: Book): Promise { const document = this.collection.codec.serialize(book); await this.collection.native.replaceOne({ _id: document._id } as Filter, document, { upsert: true }); } observeForAuthor(authorId: AuthorId): ObservableSource { const field = this.collection.codec.fieldName('authorId'); // BookId and AuthorId both use Guid; the key-field codec also encodes this author id. const storedId = this.collection.codec.id(authorId); return this.collection.observe({ [field]: storedId }); } } ``` In `main.ts`, import `Book`, `BookRepository`, and `MongoBookRepository`, then add `Book` to the existing `readModels` list and register the repository **before** `builder.discover(...)`. Keep the chapter-1 tenant resolver, author repository, and chapter-2 unique index startup: * TypeScript (preview) ```typescript // main.ts — merge these lines into the existing builder setup, before discover(). import { mongoCollection } from '@cratis/arc.mongodb'; // Import Book, BookRepository, and MongoBookRepository from ./Features/Books/. builder.withMongoDB({ server: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017', database: 'Library', readModels: [Author, Book] }); builder.services.addScoped(BookRepository, async scope => new MongoBookRepository(await scope.resolve(mongoCollection(Book)))); ``` Regenerate with `npm run generate` after adding the files. The [MongoDB setup](/arc/backend/typescript/mongodb/getting-started/) explains collection tokens and model registration in more detail. ## Read a catalog back, filtered The read model is just the book document — it carries the `AuthorId` it belongs to. The query takes the author’s id (Arc binds it by matching the parameter name) and returns only that author’s books, live: * C# ```csharp [ReadModel] public record Book(BookId Id, AuthorId AuthorId, BookTitle Title) { public static ISubject> BooksForAuthor(AuthorId authorId, IMongoCollection books) => books.Observe(b => b.AuthorId == authorId); } ``` * Java ```java @ReadModel @AllowAnonymous public record Book(BookId id, AuthorId authorId, BookTitle title) { public static Flow.Publisher> booksForAuthor( AuthorId authorId, @FromServices MongoObservableQuery queries ) { return MongoObservations.observe( queries, Book.class, Criteria.where("authorId").is(authorId.value())); } } ``` * Kotlin ```kotlin @ReadModel @AllowAnonymous data class Book(val id: BookId, val authorId: AuthorId, val title: BookTitle) { companion object { @JvmStatic fun booksForAuthor( authorId: AuthorId, @FromServices queries: MongoObservableQuery ): Flow> = queries.observe(Criteria.where("authorId").`is`(authorId.value())) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { argument, query, readModel, service, type ObservableSource } from '@cratis/arc.core'; @readModel() export class Book { @field(BookId) id!: BookId; @field(AuthorId) authorId!: AuthorId; @field(BookTitle) title!: BookTitle; @query({ observable: true }, argument('authorId', AuthorId), service(BookRepository)) static booksForAuthor(authorId: AuthorId, books: BookRepository): ObservableSource { return books.observeForAuthor(authorId); } } ``` The filter is the only difference worth noting: C# expresses it as a typed predicate over the read model, the JVM passes Spring Data `Criteria` naming the stored field, and TypeScript’s MongoDB repository observes a filter on the collection’s mapped `authorId` field. Each returns a stream — `ISubject<…>`, `Flow>`, or `ObservableSource` — which is what makes the query live. On the TypeScript relational branch, the Drizzle repository uses `eq(books.authorId, authorId.value)` and announces changes after committed writes. **On the relational branch**, read from the configured relational store instead: * C# ```csharp [ReadModel] public record Book(BookId Id, AuthorId AuthorId, BookTitle Title) { public static ISubject> BooksForAuthor(AuthorId authorId, LibraryDbContext db) => db.Books.Observe(b => b.AuthorId == authorId); } ``` * Java ```java @Entity @Table(name = "books") class BookRow { @Id UUID id; @Column(name = "author_id", nullable = false) UUID authorId; @Column(name = "title", nullable = false) String title; protected BookRow() {} BookRow(UUID id, UUID authorId, String title) { this.id = id; this.authorId = authorId; this.title = title; } } @ReadModel @AllowAnonymous public record Book(BookId id, AuthorId authorId, BookTitle title) { public static Flow.Publisher> booksForAuthor( AuthorId authorId, @FromServices JpaObservableQuery queries ) { return queries.observePublisher(Book.class, em -> em.createQuery("select b from BookRow b where b.authorId = :authorId", BookRow.class) .setParameter("authorId", authorId.value()).getResultList().stream() .map(row -> new Book(new BookId(row.id), new AuthorId(row.authorId), new BookTitle(row.title))) .toList()); } } ``` * Kotlin ```kotlin @Entity @Table(name = "books") class BookRow( @field:Id var id: UUID = UUID(0, 0), @field:Column(name = "author_id", nullable = false) var authorId: UUID = UUID(0, 0), @field:Column(name = "title", nullable = false) var title: String = "" ) @ReadModel @AllowAnonymous data class Book(val id: BookId, val authorId: AuthorId, val title: BookTitle) { companion object { @JvmStatic fun booksForAuthor(authorId: AuthorId, @FromServices queries: JpaObservableQuery): Flow> = queries.observe(Book::class.java, JpaSnapshotQuery { em -> em.createQuery("select b from BookRow b where b.authorId = :authorId", BookRow::class.java) .setParameter("authorId", authorId.value()).resultList .map { Book(BookId(it.id), AuthorId(it.authorId), BookTitle(it.title)) } }) } } ``` * TypeScript (preview) ```typescript import { eq } from 'drizzle-orm'; import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; import type { SQLJsDatabase } from 'drizzle-orm/sql-js'; import { field } from '@cratis/fundamentals'; import { argument, query, readModel, service, type ObservableSource } from '@cratis/arc.core'; import { DrizzleDialect, guidCodec, sqliteColumn, type DrizzleHandle, type DrizzleReadModels } from '@cratis/arc.drizzle'; export const books = sqliteTable('books', { id: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('id').primaryKey(), authorId: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('author_id').notNull(), title: text('title').notNull() }); export class DrizzleBookRepository extends BookRepository { constructor(private readonly writer: DrizzleHandle, private readonly records: DrizzleReadModels) { super(); } async save(book: Book): Promise { this.writer.native.insert(books).values({ id: book.id.value, authorId: book.authorId.value, title: book.title.value }).run(); this.writer.notifyChanged(books); } observeForAuthor(authorId: AuthorId): ObservableSource { return this.records.observe(eq(books.authorId, authorId.value)); } } @readModel() export class Book { @field(BookId) id!: BookId; @field(AuthorId) authorId!: AuthorId; @field(BookTitle) title!: BookTitle; @query({ observable: true }, argument('authorId', AuthorId), service(BookRepository)) static booksForAuthor(authorId: AuthorId, repository: BookRepository): ObservableSource { return repository.observeForAuthor(authorId); } } // Register Book against books in withDrizzle({ readModels: [{ type: Book, table: books }], // observation: DrizzleObservation.InProcess }), and register the scoped BookRepository. ``` For EF Core, also add `public DbSet Books => Set();` to `LibraryDbContext`. Apply the schema change before running: recreate only the disposable tutorial SQLite database as explained in [setup](/arc/backend/csharp/getting-started/), or use a migration for retained data. Re-register an author after recreating it. MongoDB creates its book collection on the first write. On the JVM, the snapshot query projects mapped JPA rows into the `Book` read model; configure the repository to persist those rows and publish changes for `Book` after commit. JPA observation is in-process only. TypeScript’s Drizzle observation also requires explicit table registration and change announcements after writes. The tab shows the SQLite/`sql.js` adapter; [observe SQL tables](/arc/backend/typescript/sql/observing-tables/) shows the `withDrizzle` registration, schema setup, and scoped repository pattern. SQL writes must be committed before `notifyChanged` when a transaction is owned outside Arc. Before adding React, regenerate the proxies — `dotnet build -c Debug` on C#, `./gradlew generateArcProxies` on the JVM, or `npm run generate` on TypeScript (using the [application’s generator script](/arc/backend/typescript/getting-started/create-an-application/)). On C# and JVM, verify `src/Books/AddBook.ts` and `src/Books/Book.ts` exist; the latter includes `BooksForAuthor` in source-file output mode. With the TypeScript backend’s co-located `--use-proxy-file-suffix` setup, import `AllAuthors` in `src/Catalog.tsx` from `../Features/Authors/AllAuthors.proxy`, `BooksForAuthor` from `../Features/Books/BooksForAuthor.proxy`, and `AddBook` in `src/AddBook.tsx` from `../Features/Books/AddBook.proxy`. The `src/Books/Book` and `src/Books/AddBook` imports in the React examples below are for the .NET/JVM source-file grouping. The relationship is a filter, not an existence constraint `Observe(b => b.AuthorId == authorId)` reads books carrying this author’s id, without a join. The form supplies an existing author, but these examples do not prevent a direct caller from submitting an unknown id. Add server validation and, where supported, a database foreign-key constraint if author existence is a required invariant. ## Showing the catalog `BooksForAuthor` takes an author id, so it’s a query *per author*. The list of authors comes from chapter 1’s `AllAuthors`; each row renders its own live catalog: src/Catalog.tsx ```tsx import { Guid } from '@cratis/fundamentals'; import { useDialog } from '@cratis/arc.react/dialogs'; import { AddBookDialog } from './AddBook'; import { AllAuthors } from './Authors/Author'; import { BooksForAuthor } from './Books/Book'; // generated proxies const AuthorBooks = ({ authorId }: { authorId: Guid }) => { const [books] = BooksForAuthor.use({ authorId }); // live, scoped to this author return
    {books.data.map(b =>
  • {b.title}
  • )}
; }; export const Catalog = () => { const [authors] = AllAuthors.use(); const [AddBookDialogWrapper, showAddBook] = useDialog(AddBookDialog); return ( <>
    {authors.data.map(a => (
  • {a.name}
  • ))}
); }; ``` Adding a book is a `CommandDialog`, exactly like `RegisterAuthor` — the only difference is that it carries the `authorId` of the author you’re adding to. Pass that in as an initial value: src/AddBook.tsx ```tsx import { useState } from 'react'; import { Guid } from '@cratis/fundamentals'; import { CommandDialog } from '@cratis/components/CommandDialog'; import { InputTextField } from '@cratis/components/CommandForm'; import { AddBook } from './Books/AddBook'; export const AddBookDialog = ({ authorId }: { authorId: Guid }) => { const [bookId] = useState(() => Guid.create()); return ( command={AddBook} title="Add book" okLabel="Add" initialValues={{ authorId, bookId }}> value={i => i.title} title="Title" /> ); }; ``` Why initialValues, not onBeforeExecute Both `authorId` and `bookId` are required and must be in `initialValues` before confirmation can become enabled. Keep the selected author’s generated `Guid` value. Mount a fresh `AddBookDialog` per operation through the [dialog composition](/arc/frontend/react/dialogs/) and unmount on close: the id stays stable while editing but is not reused for the next book. `onBeforeExecute` cannot satisfy an unset required field. ## Mount the catalog Replace `src/App.tsx` from chapter 1 with this composition: import `Catalog` instead of `Authors` and render `` instead of ``. Keep the Add author action and both providers: src/App.tsx ```tsx import { Arc } from '@cratis/arc.react'; import { useDialog } from '@cratis/arc.react/dialogs'; import { CratisComponentsProvider } from '@cratis/components'; import { AddAuthor } from './AddAuthor'; import { Catalog } from './Catalog'; function Library() { const [AddAuthorDialog, showAddAuthor] = useDialog(AddAuthor); return ( <> ); } export const App = () => ( ); ``` `Catalog` owns one dialog wrapper, outside the author loop. Each Add book button passes that row’s `Guid` to `showAddBook`. The wrapper mounts `AddBookDialog` only while open and unmounts it on close, so its `bookId` state is fresh for the next operation. ## Check the relationship Click **Add book** beside one author, add a book, then open the dialog again to add a second book. Verify that their ids differ and only that author’s list grows. Another author’s list should remain unchanged. Run `npx tsc -p tsconfig.web.json --noEmit` (or `npx tsc6 -p tsconfig.web.json --noEmit` with the TypeScript backend) and `npx vite build` from the frontend project root: `BooksForAuthor.use({ authorId })` requires a parameter object, not a positional id or a string conversion; use the `dist-web` output from chapter 1 so the TypeScript backend’s `dist/main.js` survives the Vite build. ## What you built * A second slice — `AddBook` — that tags each book with the author it belongs to. * A **relationship as a foreign key + a filtered query**: `BooksForAuthor` reads exactly one author’s catalog, live, with no join. * A React screen that composes the two: a list of authors, each rendering its own live catalog. Two features, related, both live. We keep *saying* “it stays live” — next we’ll make that concrete and watch a screen update itself the instant the data changes. [Let’s make it live →](/arc/tutorial/real-time/) # 1. Your first full-stack slice > Connect the standalone author command and observable query to React, initialize required identifiers, and verify a persistent round trip. Every app starts with one feature. Ours is **registering an author** — the first thing a librarian does before cataloging a book. You have a command that writes and a query that reads; now let’s connect them to a screen without a hand-written API client. ## Prerequisites Complete [standalone ASP.NET Core setup](/arc/backend/csharp/getting-started/) and [Your first command and query](/arc/backend/csharp/getting-started/your-first-command/) first. Those pages supply the packages, global imports, `Library.Authors` namespace, database configuration/schema, and Debug proxy output. Choose **MongoDB or EF Core** consistently; neither requires Chronicle. For the TypeScript backend, start with [the standalone MongoDB host](/arc/arc-without-event-sourcing/#a-standalone-slice-end-to-end) and [TypeScript setup](/arc/backend/typescript/getting-started/create-an-application/) instead; it runs on port 3000 and requires generated artifact metadata. Building on Kotlin or Java? Start from [Get started with Kotlin](/arc/backend/kotlin/get-started/) or [Get started with Java](/arc/backend/kotlin/get-started/java/) instead: JDK 17, Gradle, the `io.cratis.arc` plugin, and `arc-spring-boot-starter` take the place of the .NET SDK and the `Cratis.Arc.*` packages. Where this tutorial says MongoDB or EF Core, read [Spring Data MongoDB or Spring Data JPA](/arc/backend/kotlin/guides/spring-data/). Every backend excerpt below has a tab for your language; the setup commands and the React half are covered per backend in a note. The setup below adds Vite in the same `Library/` project root; keep components under `src/` and generated files in `src/Authors/`. Do not duplicate the backend types you already created. This tutorial uses the **optional Components dialog UI**; the [frontend getting-started checkpoint](/arc/frontend/react/getting-started/) puts the same command in an inline `CommandForm` instead. With our source-file proxy grouping, import its `AllAuthors` from `./Authors/Author`, not `./Authors/AllAuthors`. ## Follow the backend half Arc doesn’t force a folder layout. Here the author behavior stays together in `src/Authors/`: strong domain values (`AuthorId`, `AuthorName`), a command, and a read model with a query. Those files use `ConceptAs` and `ConceptAs` so the C# compiler distinguishes domain values while the generated client retains their underlying types. ``` Handle writes AllAuthors generated proxy RegisterAuthor database Author observable query React list ``` On TypeScript, create `Features/Authors/AuthorId.ts` and `AuthorName.ts` as separate files first. Put `RegisterAuthor` in `Features/Authors/RegisterAuthor.ts` and `Author` in `Features/Authors/Author.ts`. Import `AuthorId`, `AuthorName`, and the runtime `AuthorRepository` from their feature files (with `.js` suffixes). The concepts match the `NoteId` and `NoteText` pattern in [TypeScript setup](/arc/backend/typescript/getting-started/create-an-application/): * TypeScript (preview) Features/Authors/AuthorId.ts ```typescript import { ConceptAs, Guid } from '@cratis/fundamentals'; export class AuthorId extends ConceptAs { static readonly valueType = Guid; static create(): AuthorId { return new AuthorId(Guid.create()); } } // Features/Authors/AuthorName.ts — keep this declaration in its own file. export class AuthorName extends ConceptAs { static readonly valueType = String; } ``` These are **excerpts from the backend checkpoint**, not additional files. Each tab shows the same slice over that backend’s document store — MongoDB on C#, Spring Data MongoDB on the JVM: * C# ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } [ReadModel] public record Author(AuthorId Id, AuthorName Name) { public static ISubject> AllAuthors(IMongoCollection authors) => authors.Observe(); } ``` * Java ```java @Command @AllowAnonymous public record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); } } @ReadModel @AllowAnonymous public record Author(AuthorId id, AuthorName name) { public static Flow.Publisher> allAuthors(@FromServices MongoObservableQuery queries) { return queries.observePublisher(Author.class); } } ``` * Kotlin ```kotlin @Command @AllowAnonymous data class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) } } @ReadModel @AllowAnonymous data class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic fun allAuthors(@FromServices queries: MongoObservableQuery): Flow> = queries.observe() } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject, query, readModel, service, type ObservableSource } from '@cratis/arc.core'; @command() export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @inject(AuthorRepository) handle(authors: AuthorRepository): Promise { return authors.save({ id: this.id, name: this.name }); } } @readModel() export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource { return authors.observeAll(); } } ``` The JVM’s `@AllowAnonymous` on these tutorial artifacts allows the unauthenticated first round trip; chapter 5 replaces it with role-based access. The dependencies are **method-injected**: straight into `Handle()` and the static query method on C#, through `@FromServices` on `handle` and the companion-object query function on the JVM, and through `@inject` or `service(...)` on TypeScript. Returning `Task`, `Unit`, or `Promise` adds no payload: the explicit database write is the outcome. The query is the static method — `@JvmStatic` on the read model’s companion object in Kotlin — and none of the three backends needs a controller or a duplicated transport model. **If you chose the relational branch**, .NET injects `LibraryDbContext` instead of the collection. The JVM and TypeScript backend snippets use repositories, whose implementations supply the relational storage: * C# ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public async Task Handle(LibraryDbContext db) { db.Authors.Add(new Author(Id, Name)); await db.SaveChangesAsync(); } } [ReadModel] public record Author(AuthorId Id, AuthorName Name) { public static ISubject> AllAuthors(LibraryDbContext db) => db.Authors.Observe(); } ``` * Java ```java @Entity @Table(name = "authors") class AuthorRow { @Id UUID id; @Column(name = "name", nullable = false) String name; protected AuthorRow() {} AuthorRow(UUID id, String name) { this.id = id; this.name = name; } } @Command @AllowAnonymous public record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); } } @ReadModel @AllowAnonymous public record Author(AuthorId id, AuthorName name) { public static Flow.Publisher> allAuthors(@FromServices JpaObservableQuery queries) { return queries.observePublisher(Author.class, em -> em.createQuery("select a from AuthorRow a", AuthorRow.class).getResultList().stream() .map(row -> new Author(new AuthorId(row.id), new AuthorName(row.name))).toList()); } } ``` * Kotlin ```kotlin @Entity @Table(name = "authors") class AuthorRow( @field:Id var id: UUID = UUID(0, 0), @field:Column(name = "name", nullable = false) var name: String = "" ) @Command @AllowAnonymous data class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) } } @ReadModel @AllowAnonymous data class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic fun allAuthors(@FromServices queries: JpaObservableQuery): Flow> = queries.observe(Author::class.java, JpaSnapshotQuery { em -> em.createQuery("select a from AuthorRow a", AuthorRow::class.java) .resultList.map { Author(AuthorId(it.id), AuthorName(it.name)) } }) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject, query, readModel, service, type ObservableSource } from '@cratis/arc.core'; @command() export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @inject(AuthorRepository) handle(authors: AuthorRepository): Promise { return authors.save({ id: this.id, name: this.name }); } } @readModel() export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource { return authors.observeAll(); } } ``` On the JVM, keep `Author` as the Arc read-model DTO and map `AuthorRow` as a JPA entity. Implement `AuthorRepository` using `JpaRepository` (or the Java equivalent), converting the concept id and name to stored scalars on writes and lookups. The custom [`JpaObservableQuery` snapshot](/arc/backend/kotlin/guides/spring-data/observable-snapshots/) projects rows back into `Author`; after each committed write, publish `Author::class.java` (Java: `Author.class`) through `DatabaseChangePublisher` so subscribers receive a fresh snapshot. Saving a row alone does not publish an Arc read-model change. JPA observation is in-process only. On TypeScript, define your Drizzle `authors` table with `id` and `name` columns (the [unique-name definition in chapter 2](/arc/tutorial/validation/#install-the-database-constraint) adds the constraint), implement `AuthorRepository` over it, register the table in `withDrizzle({ readModels: [{ type: Author, table: authors }], observation: DrizzleObservation.InProcess, ... })`, and call `notifyChanged(authors)` after committed writes so [in-process table observation](/arc/backend/typescript/sql/observing-tables/) updates subscribers. Drizzle observation does not see writes from other processes. Separate decisions as behavior grows This tutorial uses supported direct database writes to make the first round trip visible. On C# and TypeScript, for immediate writes that new model-bound behavior can describe from its inputs, prefer command operations ([C#](/arc/backend/csharp/commands/operations/), [TypeScript](/arc/backend/typescript/commands/operations/)): the handler returns the intended work and Arc performs it afterward. This separates decision tests from provider interaction without requiring Chronicle or changing the query into a write. Command operations are not available on the JVM; keep the direct repository write shown here, and test the decision through [in-process command scenarios](/arc/backend/kotlin/guides/testing/). Live means a configured observation source MongoDB requires change streams on a replica set. EF Core receives same-host `SaveChanges` notifications through the configured interceptor; SQLite does not detect external-process writes. Keep the [storage prerequisites](/arc/backend/csharp/getting-started/) in place. An observable return type exposes a stream; it cannot make an unconfigured database emit changes. The JVM boundary is the same shape: MongoDB observation rides change streams and needs the same replica set, while JPA observation is in-process only and needs a published change notification — see [Observable storage snapshots](/arc/backend/kotlin/guides/spring-data/observable-snapshots/). ## Build before importing ```bash dotnet build -c Debug ``` With `Cratis.Arc.ProxyGenerator.Build` installed and `CratisProxiesOutputPath` set, a post-build tool reads the assembly and PDB and generates `src/Authors/RegisterAuthor.ts` and `src/Authors/Author.ts`. The latter includes `AllAuthors` in source-file output mode. Building a bare Arc project without generator configuration emits no proxies. The same step on the JVM Run `./gradlew generateArcProxies` (the task is also attached to `build`). KSP writes the Arc artifact manifest at compile time and the `io.cratis.arc` plugin’s generator turns it into the same TypeScript. Point `cratisArc.proxies.outputDirectory` at your frontend source folder — an unset output directory skips generation, exactly as an unset `CratisProxiesOutputPath` does. Route settings exist on both the build side (`cratisArc.endpoints.*`) and the runtime side (`cratis.arc.endpoints.*`) and must agree; the starter fails startup when they do not. See [Generate TypeScript proxies](/arc/backend/kotlin/guides/typescript-proxies/). On TypeScript, run `npm run generate` from the application built in [TypeScript setup](/arc/backend/typescript/getting-started/create-an-application/); its `arc-proxygenerator` script writes generated metadata and co-located `*.proxy.ts` files. From `src/Authors.tsx` import `AllAuthors` from `../Features/Authors/AllAuthors.proxy`; from `src/AddAuthor.tsx` import `RegisterAuthor` from `../Features/Authors/RegisterAuthor.proxy`. The .NET/JVM `src/Authors/Author` and `src/Authors/RegisterAuthor` paths in the examples below do not exist on this backend. Regenerate after changing a backend artifact. If you customize backend routes, mirror them in the [build-time proxy configuration](/arc/backend/csharp/proxy-generation/configuration/). Generated clients do not discover runtime route changes. ## Set up the browser project If you already have a Vite app, retain its entry/configuration and adapt only the composition below. Otherwise, from `Library/`, initialize it without a Chronicle-enabled template: ```bash npm init -y npm pkg set type=module npm install react@^19 react-dom@^19 @cratis/fundamentals@^7.19.6 @cratis/arc @cratis/arc.react \ @cratis/components@^4 reflect-metadata@0.2.2 tsyringe@4.10.0 npm install --save-dev vite @types/react @types/react-dom ``` For a .NET or JVM-only project without TypeScript installed yet, also run `npm install --save-dev typescript`. If you followed the TypeScript backend setup, **keep its existing TypeScript 6 compiler alias** (`tsc6`); installing plain `typescript` here would replace it. `type=module` lets Vite load `vite.config.ts` as an ES module without a CommonJS warning. Use Node.js supported by your chosen Vite release. Keep `@cratis/arc` and `@cratis/arc.react` on the same version used by your generated proxies, within Components’ peer range (`>=20.3.1 <23`). For the TypeScript backend sample, pin both to **22.19.1**: `npm install @cratis/arc@22.19.1 @cratis/arc.react@22.19.1`. `@cratis/arc.react` depends on that exact Arc version; mixing versions installs a second copy, and generated proxies fail the frontend type-check because the runtime classes have private members. The command installs Components and its required peers; this dialog needs neither a separate UI kit nor the optional `pixi.js` peer used by spatial components. Components is required for this chapter’s `CommandDialog`, not for Arc itself. See [Components setup](/components/getting-started/) for other installation options. If you followed the TypeScript backend setup, **keep its `tsconfig.json`**: the generator’s `--project` points to it, and its `main.ts` and `Features/**/*.ts` entries emit the backend’s `dist/main.js`. Add a separate `tsconfig.web.json` for Vite instead. The .NET and JVM projects can also use this separate frontend config: ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"] }, "include": ["src/**/*.ts", "src/**/*.tsx", "Features/**/*.proxy.ts", "Features/**/*.tsx"] } ``` The `Features` entries type-check the TypeScript backend’s co-located browser proxies without adding them to its server build. Keep `generate.mjs` on `--project ./tsconfig.json`, **not** `tsconfig.web.json`; the latter has no backend artifacts. Create `index.html` at the project root: ```html Library
``` Create `vite.config.ts`: ```typescript import { defineConfig } from 'vite'; export default defineConfig({ oxc: { decorator: { legacy: true } }, build: { outDir: 'dist-web' }, server: { host: 'localhost', port: 5173, strictPort: true, proxy: { '/api': { target: 'http://localhost:5000' }, '/.cratis': { target: 'http://localhost:5000', ws: true } } } }); ``` Vite needs `oxc.decorator.legacy` for generated proxies’ `@field` decorators because it reads decorator settings from `tsconfig.json`, not `tsconfig.web.json`. The separate `dist-web` output also keeps Vite from emptying the TypeScript backend’s `dist/main.js`. The proxy sends same-origin command/query and identity/stream requests to the backend. `ws: true` also permits the optional WebSocket transport. Keep both prefixes, even when using the default SSE transport. Port 5000 is this tutorial’s ASP.NET Core host. A Spring Boot host listens on **8080** by default; the [TypeScript standalone host](/arc/arc-without-event-sourcing/#a-standalone-slice-end-to-end) uses **3000**. Point both proxy targets at that backend’s loopback port (or set `server.port=5000` for Spring Boot), and keep the rest of the file as it is. Everything from here to the end of the chapter is generated-proxy and React code, identical on all three backends. ## Read and write from React 1. **Read authors with the query proxy.** Create `src/Authors.tsx`: ```tsx import { AllAuthors } from './Authors/Author'; export const Authors = () => { const [authors] = AllAuthors.use(); return (
    {authors.data.map(a =>
  • {a.name}
  • )}
); }; ``` The hook subscribes to the observable query and re-renders on each new result. String conversion is only for React’s key; retain the generated `Guid` when passing ids to commands and queries. 2. **Add the command dialog.** Create `src/AddAuthor.tsx`: ```tsx import { useState } from 'react'; import { Guid } from '@cratis/fundamentals'; import { CommandDialog } from '@cratis/components/CommandDialog'; import { InputTextField } from '@cratis/components/CommandForm'; import { RegisterAuthor } from './Authors/RegisterAuthor'; export const AddAuthor = () => { const [id] = useState(() => Guid.create()); return ( command={RegisterAuthor} title="Add author" okLabel="Add" initialValues={{ id }}> value={i => i.name} title="Name" /> ); }; ``` Open this through the composition below: **mount a new instance per operation and unmount on close**. The id remains stable while editing and is fresh next time. It is required, so it belongs in `initialValues`, not `onBeforeExecute` (which runs too late to enable confirmation). ## Mount the screen and verify the round trip Create `src/App.tsx`: ```tsx import { Arc } from '@cratis/arc.react'; import { useDialog } from '@cratis/arc.react/dialogs'; import { CratisComponentsProvider } from '@cratis/components'; import { AddAuthor } from './AddAuthor'; import { Authors } from './Authors'; function Library() { const [AddAuthorDialog, showAddAuthor] = useDialog(AddAuthor); return ( <> ); } export const App = () => ( ); ``` `useDialog` mounts its content only while open, so each registration gets new id state. `` supplies runtime bindings; `CratisComponentsProvider` supplies the UI library context and does not replace it. No generated `Bindings` file is needed. Create `src/main.tsx` and import Components’ styles once, with tokens first: ```tsx import 'reflect-metadata'; import '@cratis/components/tokens'; import '@cratis/components/styles'; import '@cratis/components/theme'; import { createRoot } from 'react-dom/client'; import { App } from './App'; createRoot(document.getElementById('root')!).render(); ``` `tokens` supplies shared CSS variables, `styles` supplies component structure, and `theme` adds the baseline appearance, including dark mode. You can omit `theme` when a product design system supplies its own token values. Those three subpaths are stylesheets, so they ship no type declarations. Current TypeScript releases check side-effect imports and report `TS2882: Cannot find module or type declarations for side-effect import` for each one. Declare them once in `src/cratis-components-styles.d.ts`: ```typescript declare module '@cratis/components/tokens'; declare module '@cratis/components/styles'; declare module '@cratis/components/theme'; ``` This keeps side-effect checking on for every other import, so a mistyped package name still fails the type-check. Keep the backend running on the port your Vite proxy targets — 5000 for ASP.NET Core, 8080 for default Spring Boot, or 3000 for the TypeScript standalone host. From another terminal in `Library/`, run: ```bash npx tsc -p tsconfig.web.json --noEmit npx vite build npx vite ``` For the TypeScript backend setup, use `npx tsc6 -p tsconfig.web.json --noEmit` in place of `npx tsc`, keeping `npm run generate` on the original backend `tsconfig.json` before building the browser project. Open `http://localhost:5173`. The first two commands check TypeScript and the production bundle; only the following browser checks prove the round trip: 1. Open the author list and add an author with a nonblank name. 2. Confirm that the list displays the saved name without refresh. 3. Open a **new** dialog and add another author. Inspect the query data: the two ids must differ. 4. Reload the browser and confirm both authors remain. These checks prove a command/query/persistence round trip. A successful build alone proves none of those effects. ## Where type safety lives The accessor `i => i.name` is a property on the generated command, not a string. This backend excerpt supplies that contract in each language: * C# ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } ``` * Java ```java @Command @AllowAnonymous public record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); } } ``` * Kotlin ```kotlin @Command @AllowAnonymous data class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; @command() export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @inject(AuthorRepository) handle(authors: AuthorRepository): Promise { return authors.save({ id: this.id, name: this.name }); } } ``` The React fragment uses the generated proxy (the `id` state is initialized in `AddAuthor` above): ```tsx command={RegisterAuthor} title="Add author" initialValues={{ id }}> value={i => i.name} title="Name" /> ``` Rename `Name` in C# (or `name` on Kotlin, Java, or TypeScript), regenerate the proxies with `dotnet build -c Debug`, `./gradlew generateArcProxies`, or `npm run generate` respectively, then run the frontend type-check. The old accessor fails compilation. Never repair this by editing generated code. ## What you built One feature now has an explicit write, a live read, and typed React callers — all over current-state storage. There’s still a problem: blank and duplicate names are accepted. [Let’s make it trustworthy →](/arc/tutorial/validation/) # 4. Make it live > Verify two-browser updates through an observable query and distinguish MongoDB change streams from EF Core same-host and database notifications. We keep saying the catalog “stays live.” This chapter makes that concrete — and shows that you already built it, without writing a line of real-time plumbing. ## Why the screen already moves Go back to every query we’ve written: each observes storage rather than returning a one-shot list. The C# `ISubject<…>`, JVM `Flow>`, and TypeScript `ObservableSource<…>` all expose streams. Arc serves each as an observable query; on the React side, the generated `.use()` hook **subscribes** to it. So the data flow isn’t “fetch once and render” — it’s a standing subscription: ``` writes notification AddBook Books BooksForAuthor.use() React re-renders ``` When a command writes and the configured observation source notifies, the observable query publishes updated data to subscribed React components, which re-render. Open the app in two browser tabs, add a book in one, and watch it appear in the other — you wrote none of that plumbing. It falls out of returning `Observe()` and calling `.use()`. Under the hood The subscription rides Server-Sent Events: the browser holds an open connection and Arc pushes each new value down it. You don’t manage the connection, and you don’t poll. On the backend, MongoDB uses [change streams](/arc/backend/csharp/mongodb/observing-collections/) on a replica set or supported sharded deployment (including the TypeScript MongoDB adapter). An [observed `DbSet`](/arc/backend/csharp/entity-framework/observing/) receives in-process notifications from the configured `SaveChanges` interceptor. SQLite relies on that path only; SQL Server/PostgreSQL cross-process detection requires their database notification setup. If database notification setup fails, EF observation can fall back to in-process-only behavior; inspect server logs rather than assuming external writes are visible. If you ever want to watch the raw stream, you can — see [observable queries with cURL](/arc/backend/csharp/queries/using-observable-queries-with-curl/). ## Verify the scope of observation Continue from [chapter 3](/arc/tutorial/books-and-relationships/). Open two browser tabs connected to the **same backend process**. Subscribe to the same author’s catalog in both; add a book in one and verify the other updates without reload. This tests the tutorial’s live path, including SQLite. It does not prove cross-process observation. External writes are a separate test: MongoDB change streams can report them when configured; SQLite cannot. For EF’s other providers, configure their database notification mechanism before testing a write from a second process. No notification source means an observable endpoint can stay connected without fresh data. ## What makes a query live The following are **alternative MongoDB method fragments** in `Author`, using the tutorial imports. Replace the method rather than adding a second method with the same signature. Nothing special — just the return type. A one-shot query returns the data: * C# ```csharp public static IEnumerable AllAuthors(IMongoCollection authors) => authors.Find(_ => true).ToList(); ``` * Java ```java public static List allAuthors(@FromServices AuthorRepository authors) { return authors.findAll(); } ``` * Kotlin ```kotlin @JvmStatic fun allAuthors(@FromServices authors: AuthorRepository): List = authors.findAll() ``` * TypeScript (preview) ```typescript @query(service(AuthorRepository)) static allAuthors(authors: AuthorRepository): Promise { return authors.all(); } ``` Make it return a stream instead — an `ISubject<…>` on C#, a `Flow>` on the JVM, or an `ObservableSource` on TypeScript — and the method becomes a live subscription. The proxy and the React `.use()` adapt automatically, and the frontend code is identical for every backend: * C# ```csharp public static ISubject> AllAuthors(IMongoCollection authors) => authors.Observe(); ``` * Java ```java public static Flow.Publisher> allAuthors(@FromServices MongoObservableQuery queries) { return queries.observePublisher(Author.class); } ``` * Kotlin ```kotlin @JvmStatic fun allAuthors(@FromServices queries: MongoObservableQuery): Flow> = queries.observe() ``` * TypeScript (preview) ```typescript @query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource { return authors.observeAll(); } ``` That’s the whole switch between “load once” and “stay live.” You choose per query: a rarely-changing lookup can be a plain list; a screen the librarian watches should observe. ## What you built * A clear picture of **why your screens update themselves** — observable queries are standing subscriptions, database to browser. * The method-level difference between a **one-shot** query and a **live** one — the return type and observation call, with a configured notification source underneath. The catalog is live. There’s just one thing left before it’s a real back office: right now *anyone* can register authors and add books. Let’s decide who’s allowed. [Lock it down →](/arc/tutorial/authorization/) # 2. Make it trustworthy > Reject a blank or duplicate author name with a value-type validator and a business rule, and watch the reason appear in the React form. We left chapter 1 with a working slice and an honest problem: a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A trustworthy app has to be able to say *no* — clearly, and as early as possible. There are two different kinds of “no” here, and Arc handles them in two different places: * **“That input is malformed.”** A blank name is wrong on its face — you don’t need to look at anything else to know it. That’s a *validation* rule, and it belongs on the value. * **“That input collides with what already exists.”** A duplicate name is only wrong relative to the rest of the system. That’s a *business rule*, and it needs to consult state. Let’s add both. This chapter extends the files and imports from [chapter 1](/arc/tutorial/first-slice/). On C#, put each validator in its own file under `src/Authors/` with `namespace Library.Authors;`; choose the same database branch. On the JVM, put each validator in its own file in the same package as the command and mark it `@Component` so Spring registers it. On TypeScript, put the validators under the `Features/` root used by both `discover()` and the generator; regenerate the metadata so the host discovers them. Name comparison is exact in this lesson, not case-folded or normalized. ## Validate the value, once Your first instinct might be to validate `Name` inside a `RegisterAuthor` validator. But think about it: *every* command that takes an `AuthorName` wants the same guarantee — never blank. Arc lets you state that rule **on the value type itself**, so it’s enforced everywhere an `AuthorName` is used, and you never write it twice. 1. **Write a `ConceptValidator` for the value type.** On C# it reaches into the concept’s underlying `Value` and applies ordinary [FluentValidation](https://docs.fluentvalidation.net/) rules; on the JVM it implements Arc’s `ConceptValidator` interface and reads the same underlying value through `value()`; on TypeScript the rule targets `name.value` with `notEmpty()`: * C# ```csharp public class AuthorNameValidator : ConceptValidator { public AuthorNameValidator() { RuleFor(x => x.Value).NotEmpty().WithMessage("An author needs a name."); } } ``` * Java ```java @Component public final class AuthorNameValidator implements FundamentalsConceptValidator { @Override public Class getConceptType() { return AuthorName.class; } @Override public List validate(AuthorName concept) { return concept.value().isBlank() ? List.of(ValidationResult.error("An author needs a name.")) : List.of(); } } ``` * Kotlin ```kotlin @Component class AuthorNameValidator : FundamentalsConceptValidator { override val conceptType: Class = AuthorName::class.java override fun validate(concept: AuthorName): List = if (concept.value().isBlank()) { listOf(ValidationResult.error("An author needs a name.")) } else { emptyList() } } ``` * TypeScript (preview) ```typescript import { ConceptValidator, validator } from '@cratis/arc.core'; @validator(AuthorName) export class AuthorNameValidator extends ConceptValidator { constructor() { super(); this.ruleFor(name => name.value).notEmpty().withMessage('An author needs a name.'); } } ``` That’s the whole change. C# discovers the validator by convention, with no registration at all; the JVM one is an ordinary Spring bean, so `@Component` is all the wiring it needs. TypeScript discovers the decorated validator under `Features/` when you regenerate metadata and restart the host. From then on, any command carrying an `AuthorName` rejects a blank one before the handler ever runs. ## Reject the duplicate against existing state Uniqueness is the harder kind of “no”, because it depends on what’s already there. Input validation can’t answer it — you have to look in the database. That’s a job for a **`CommandValidator`**, which validates a whole command and, because Arc constructs it from the container, can take the dependencies it needs to check state. 1. **Write a `CommandValidator` that asks the database.** Every backend takes the dependency it needs and looks for an author already carrying this name; if one exists, the command is rejected: * C# ```csharp public class RegisterAuthorValidator : CommandValidator { public RegisterAuthorValidator(IMongoCollection authors) { RuleFor(c => c.Name) .MustAsync(async (name, _) => !await authors.Find(a => a.Name == name).AnyAsync()) .WithMessage("An author with that name is already registered."); } } ``` * Java ```java public final class RegisterAuthorValidator implements BlockingCommandValidator { private final AuthorRepository authors; public RegisterAuthorValidator(AuthorRepository authors) { this.authors = authors; } @Override public Class getCommandType() { return RegisterAuthor.class; } // validate has no coroutine in its signature, so the blocking lookup is called // directly. Reach for AsyncCommandValidator when the lookup itself is asynchronous. @Override public List validate(RegisterAuthor command, CommandContext context) { return authors.existsByName(command.name()) ? List.of(ValidationResult.error( "An author with that name is already registered.", List.of("name"))) : List.of(); } } @Configuration public class RegisterAuthorValidation { @Bean public CommandValidator registerAuthorValidator(AuthorRepository authors) { return new BlockingCommandValidatorAdapter<>(new RegisterAuthorValidator(authors)); } } ``` * Kotlin ```kotlin @Component class RegisterAuthorValidator( private val authors: AuthorRepository ) : CommandValidator { override val commandType: Class = RegisterAuthor::class.java // validate suspends, so the lookup is awaited directly. override suspend fun validate( command: RegisterAuthor, context: CommandContext ): List = if (authors.existsByName(command.name)) { listOf( ValidationResult.error( "An author with that name is already registered.", listOf("name") ) ) } else { emptyList() } } ``` * TypeScript (preview) ```typescript import { CommandValidator, currentServices, validator } from '@cratis/arc.core'; @validator(RegisterAuthor) export class RegisterAuthorValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.name) .mustAsync(async (_name, command, signal) => { const authors = await currentServices().resolve(AuthorRepository); return !await authors.existsByName(command.name, signal); }) .withMessage('An author with that name is already registered.'); } } ``` The two shapes genuinely differ, and it is worth seeing why. C# expresses the lookup as FluentValidation’s `MustAsync` predicate. Arc.Kotlin has no async predicate rule to reach for — its shared fluent surface is deliberately limited to literal rules — so the JVM sibling is the plain imperative `CommandValidator` bean, whose `validate` is already a suspending function and can simply await the repository. TypeScript uses `mustAsync` on the command’s name and resolves the application-owned `AuthorRepository` token from `currentServices()` **inside** the rule. Arc constructs validators during host preflight, before there is a tenant; constructor-injecting a tenant-scoped MongoDB repository would fail startup. Every backend returns the same rejection to the caller. The handler doesn’t change — `Handle()` on C# and `handle` on the JVM and TypeScript still perform the chapter-1 write. The validator runs first; if it fails, the handler never executes. **On the relational branch**, the .NET validator injects the context instead of the collection. The JVM and TypeScript validators still ask their repository; its persistence implementation supplies the relational lookup: * C# ```csharp public class RegisterAuthorValidator : CommandValidator { public RegisterAuthorValidator(LibraryDbContext db) { RuleFor(c => c.Name) .MustAsync(async (name, ct) => !await db.Authors.AnyAsync(a => a.Name == name, ct)) .WithMessage("An author with that name is already registered."); } } ``` * Java ```java public final class RegisterAuthorValidator implements BlockingCommandValidator { private final AuthorRepository authors; public RegisterAuthorValidator(AuthorRepository authors) { this.authors = authors; } @Override public Class getCommandType() { return RegisterAuthor.class; } // validate has no coroutine in its signature, so the blocking lookup is called // directly. Reach for AsyncCommandValidator when the lookup itself is asynchronous. @Override public List validate(RegisterAuthor command, CommandContext context) { return authors.existsByName(command.name()) ? List.of(ValidationResult.error( "An author with that name is already registered.", List.of("name"))) : List.of(); } } @Configuration public class RegisterAuthorValidation { @Bean public CommandValidator registerAuthorValidator(AuthorRepository authors) { return new BlockingCommandValidatorAdapter<>(new RegisterAuthorValidator(authors)); } } ``` * Kotlin ```kotlin @Component class RegisterAuthorValidator( private val authors: AuthorRepository ) : CommandValidator { override val commandType: Class = RegisterAuthor::class.java // validate suspends, so the lookup is awaited directly. override suspend fun validate( command: RegisterAuthor, context: CommandContext ): List = if (authors.existsByName(command.name)) { listOf( ValidationResult.error( "An author with that name is already registered.", listOf("name") ) ) } else { emptyList() } } ``` * TypeScript (preview) ```typescript import { CommandValidator, currentServices, validator } from '@cratis/arc.core'; @validator(RegisterAuthor) export class RegisterAuthorValidator extends CommandValidator { constructor() { super(); this.ruleFor(command => command.name) .mustAsync(async (_name, command, signal) => { const authors = await currentServices().resolve(AuthorRepository); return !await authors.existsByName(command.name, signal); }) .withMessage('An author with that name is already registered.'); } } ``` ## Install the database constraint A check-then-write has a race: two registrations can both pass validation. The validator supplies the friendly message; the database must enforce uniqueness independently. * MongoDB In the .NET host, add the following block to `Program.cs` after `app.UseCratisArc()` and before `RunAsync()`. On the JVM, add the Spring `AuthorIndexes` component after declaring `Author`, with `MongoTemplate` on the classpath; its constructor installs the unique index at startup. On TypeScript, after registering the artifacts, repository, and MongoDB in the [standalone host](/arc/arc-without-event-sourcing/#a-standalone-slice-end-to-end), replace that host’s `builder.build()` / `app.run()` lines with `await startWithAuthorIndex(builder)`. The helper creates a scope for tenant `default`, resolves its `MongoCollection`, installs the index, disposes the scope, then runs the host. The Arc validators do not create indexes: * C# ```csharp using (var scope = app.Services.CreateScope()) { var authors = scope.ServiceProvider.GetRequiredService>(); await authors.Indexes.CreateOneAsync(new CreateIndexModel( Builders.IndexKeys.Ascending(author => author.Name), new CreateIndexOptions { Unique = true, Name = "unique_author_name" })); } ``` * Java ```java @Component class AuthorIndexes { AuthorIndexes(MongoTemplate template) { template.indexOps(Author.class) .createIndex(new Index().on("name", Sort.Direction.ASC).unique()); } } ``` * Kotlin ```kotlin @Component class AuthorIndexes(template: MongoTemplate) { init { template.indexOps(Author::class.java) .createIndex(Index().on("name", Sort.Direction.ASC).unique()) } } ``` * TypeScript (preview) ```typescript import { Severity, type ArcApplicationBuilder } from '@cratis/arc.core'; import { mongoCollection, type MongoCollection } from '@cratis/arc.mongodb'; export async function ensureAuthorNameIndex(collection: MongoCollection): Promise { await collection.native.createIndex( { [collection.codec.fieldName('name')]: 1 }, { unique: true, name: 'unique_author_name' }); } // In start(), call startWithAuthorIndex(builder) instead of its build/run lines. export async function startWithAuthorIndex(builder: ArcApplicationBuilder): Promise { const app = await builder.build(); try { const scope = app.server.services.createScope({ tenantId: 'default', correlationId: crypto.randomUUID(), principal: undefined, signal: new AbortController().signal, allowedSeverity: Severity.Warning }); try { await ensureAuthorNameIndex(await scope.resolve(mongoCollection(Author))); } finally { await scope.dispose(); } await app.run({ port: 3000 }); } finally { await app.dispose(); } } ``` Restart the host and require index creation to succeed. Existing duplicate names must be resolved first; do not ignore an index-creation failure. The C# and TypeScript examples resolve stored field names through their serializers rather than guessing them; the Spring Data example uses its mapped `name` property. * EF Core ```csharp using (var scope = app.Services.CreateScope()) { var authors = scope.ServiceProvider.GetRequiredService>(); await authors.Indexes.CreateOneAsync(new CreateIndexModel( Builders.IndexKeys.Ascending(author => author.Name), new CreateIndexOptions { Unique = true, Name = "unique_author_name" })); } ``` * C# ```java @Component class AuthorIndexes { AuthorIndexes(MongoTemplate template) { template.indexOps(Author.class) .createIndex(new Index().on("name", Sort.Direction.ASC).unique()); } } ``` * Java ```kotlin @Component class AuthorIndexes(template: MongoTemplate) { init { template.indexOps(Author::class.java) .createIndex(Index().on("name", Sort.Direction.ASC).unique()) } } ``` * Kotlin ```typescript import { Severity, type ArcApplicationBuilder } from '@cratis/arc.core'; import { mongoCollection, type MongoCollection } from '@cratis/arc.mongodb'; export async function ensureAuthorNameIndex(collection: MongoCollection): Promise { await collection.native.createIndex( { [collection.codec.fieldName('name')]: 1 }, { unique: true, name: 'unique_author_name' }); } // In start(), call startWithAuthorIndex(builder) instead of its build/run lines. export async function startWithAuthorIndex(builder: ArcApplicationBuilder): Promise { const app = await builder.build(); try { const scope = app.server.services.createScope({ tenantId: 'default', correlationId: crypto.randomUUID(), principal: undefined, signal: new AbortController().signal, allowedSeverity: Severity.Warning }); try { await ensureAuthorNameIndex(await scope.resolve(mongoCollection(Author))); } finally { await scope.dispose(); } await app.run({ port: 3000 }); } finally { await app.dispose(); } } ``` * TypeScript (preview) Install a unique constraint on the stored author name. On .NET merge the override into your existing `LibraryDbContext` (do not declare it twice); on the JVM replace the `AuthorRow` mapping from the relational slice with the constrained version. On TypeScript, define your Drizzle `authors` table with the constrained version shown here (or add `.unique()` to your chapter-1 table) and apply the updated SQL schema before accepting writes; the EF Core override does not configure it. * C# ```csharp public class LibraryDbContext : DbContext { public DbSet Authors => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity().HasIndex(author => author.Name).IsUnique(); } } ``` * Java ```java @Entity @Table(name = "authors", uniqueConstraints = @UniqueConstraint(name = "unique_author_name", columnNames = "name")) class AuthorRow { @Id UUID id; @Column(name = "name", nullable = false) String name; protected AuthorRow() {} AuthorRow(UUID id, String name) { this.id = id; this.name = name; } } ``` * Kotlin ```kotlin @Entity @Table(name = "authors", uniqueConstraints = [UniqueConstraint(name = "unique_author_name", columnNames = ["name"])]) class AuthorRow( @field:Id var id: UUID = UUID(0, 0), @field:Column(name = "name", nullable = false) var name: String = "" ) ``` * TypeScript (preview) ```typescript import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { DrizzleDialect, guidCodec, sqliteColumn } from '@cratis/arc.drizzle'; export const authors = sqliteTable('authors', { id: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('id').primaryKey(), name: text('name').notNull().unique() }); ``` For the **disposable SQLite tutorial database**, stop the host, remove only `Library/library.db`, and restart so `EnsureCreatedAsync` creates the new schema. This loses tutorial data; re-register the authors afterward. For retained data, create/apply a migration instead. `EnsureCreatedAsync` never upgrades an existing database. * C# ```csharp public class LibraryDbContext : DbContext { public DbSet Authors => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity().HasIndex(author => author.Name).IsUnique(); } } ``` * Java ```java @Entity @Table(name = "authors", uniqueConstraints = @UniqueConstraint(name = "unique_author_name", columnNames = "name")) class AuthorRow { @Id UUID id; @Column(name = "name", nullable = false) String name; protected AuthorRow() {} AuthorRow(UUID id, String name) { this.id = id; this.name = name; } } ``` * Kotlin ```kotlin @Entity @Table(name = "authors", uniqueConstraints = [UniqueConstraint(name = "unique_author_name", columnNames = ["name"])]) class AuthorRow( @field:Id var id: UUID = UUID(0, 0), @field:Column(name = "name", nullable = false) var name: String = "" ) ``` * TypeScript (preview) ```typescript import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { DrizzleDialect, guidCodec, sqliteColumn } from '@cratis/arc.drizzle'; export const authors = sqliteTable('authors', { id: sqliteColumn(guidCodec(DrizzleDialect.SQLite))('id').primaryKey(), name: text('name').notNull().unique() }); ``` These constraints belong to each store, not to Arc. On the JVM, Spring Data MongoDB index operations or a JPA unique constraint applied by your schema tooling provide the hard guarantee; on TypeScript, create the MongoDB index at startup or apply the Drizzle schema change through a migration. The validator gives the friendly message, and the database constraint holds under a race. A race is not automatically a validation result The unique index prevents the second write, but this lesson does not translate a provider duplicate-key exception into a field validation message. A racing loser can receive an exception result. Production code should catch only the specific duplicate-constraint violation and translate it to a typed validation failure; unrelated storage failures must remain errors. Moving the same pre-check into `Handle()` would not make it atomic. ## The UI already knows Here’s the part that feels like a gift. You didn’t touch the React code from chapter 1 — but the form now validates. When you wrote the `ConceptValidator`, the C# and TypeScript proxy generators extracted its portable `notEmpty` rule into the generated TypeScript too, so the `CommandDialog` runs it **on the client** and disables the confirm button on a blank name — no round trip. The uniqueness rule can’t run on the client (it has to look in the database), so it runs on the server; when it rejects, the message comes back through the same proxy and `CommandDialog` renders it against the form — *“An author with that name is already registered.”* — because the dialog wires command results to fields for you. Client-side rules are declared differently on the JVM The server half of this works the same across backends, and so does the form: a rejected command comes back through the same generated proxy and lands on the same field. The **client** half is not. An imperative Kotlin `ConceptValidator` or `CommandValidator` is server-only; registering the bean does not invent a TypeScript rule. To have the blank-name rule run in the browser too, declare it as a literal rule in a [`FluentModelValidator`](/arc/backend/kotlin/guides/validation/), which KSP does extract into the generated validator. That surface is bounded on purpose — literal string, collection, and numeric rules, no predicates and nothing asynchronous — so a rule like uniqueness stays server-side on every backend anyway. This is a fragment of `AddAuthor` from chapter 1; retain its imports and per-operation `const [id] = useState(() => Guid.create())` initialization. Mount a new dialog instance for each registration. AddAuthor.tsx — form fragment ```tsx command={RegisterAuthor} title="Add author" okLabel="Add" initialValues={{ id }}> value={i => i.name} title="Name" /> ``` One rule on the value type, one rule on the command, and both ends of the app honor them — because the validation, like the types, flows from one source. ## Verify the rejection paths Rebuild the backend to regenerate validation metadata — `dotnet build -c Debug` on C#, `./gradlew generateArcProxies` on the JVM, or `npm run generate` on TypeScript — then run the frontend type-check/build. Open a new dialog: blank Name must keep confirmation disabled. Submit a new name successfully; open another dialog and submit that exact name again. Expect a server validation message and no additional row. Confirm the unique index/schema step succeeded separately — a friendly duplicate rejection alone does not prove concurrency protection. ## What you built * An `AuthorNameValidator` that guards the **value type**, enforced everywhere an `AuthorName` is used — and, on C# and TypeScript, also run client-side through the generated proxy. * A `RegisterAuthorValidator` **business rule** that checks existing state in the database, with a unique index behind it for the hard guarantee. * A React form that surfaces every server rejection with no extra frontend code, plus the client-side rules the backend chose to share. Our author is trustworthy now. But an author with no books is a lonely thing. Next we’ll give them a catalog — a second feature that *relates* to this one. [Let’s add books →](/arc/tutorial/books-and-relationships/) # Understanding identity and access > Arc handles who the user is, what they may do and whose data they see through identity enrichment, role-based authorization and multi-tenant isolation. Three questions run through every real application: **who is this user**, **what are they allowed to do**, and — if you serve more than one customer — **whose data are they allowed to see**. Arc supplies hooks for all three, but authentication, operation authorization, tenant membership, and storage isolation remain distinct responsibilities. This page is the mental model; each section links to the reference that goes deeper. ## Who is the user — identity, enriched once Authentication establishes a `ClaimsPrincipal` — an id, a name, and claims, including any authorization roles. Your application may also need display preferences, membership information, or other domain details. Arc lets you enrich that identity without confusing the details with authoritative role claims. Implement `IProvideIdentityDetails`. During identity resolution, Arc calls it with an `IdentityProviderContext`, **not the raw token**. This illustrative MongoDB fragment assumes application-owned `Member` and `LibraryIdentity` types and the identity/provider imports; it controls the identity-details result, not access to every command: * C# ```csharp public class LibraryIdentityProvider(IMongoCollection members) : IProvideIdentityDetails { public async Task Provide(IdentityProviderContext context) { // Look the user up in your own data, keyed by the provider's id. var member = await members.Find(m => m.Subject == context.Id.Value).FirstOrDefaultAsync(); if (member is null) return new IdentityDetails(false, LibraryIdentity.None); // /.cratis/me rejects this result var identity = new LibraryIdentity(member.Id, member.Role, member.Name); return new IdentityDetails(true, identity); } } ``` * Java ```java public record LibraryIdentity(String memberId, String role, String displayName) { } @Bean public IdentityDetailsProvider identityDetails(MemberRepository members) { return new AsyncIdentityDetailsProviderAdapter<>(new AsyncIdentityDetailsProvider() { @Override public Class getDetailsType() { return LibraryIdentity.class; } // Look the user up in your own data, keyed by the provider's id. @Override public CompletionStage> provide(IdentityProviderContext context) { var member = members.bySubject(context.getId()); if (member == null) { return CompletableFuture.completedFuture( new IdentityDetails<>(false, new LibraryIdentity("", "", ""))); } return CompletableFuture.completedFuture(new IdentityDetails<>( true, new LibraryIdentity(member.id(), member.role(), member.name()))); } }); } ``` * Kotlin ```kotlin data class LibraryIdentity(val memberId: String, val role: String, val displayName: String) @Bean fun identityDetails(members: MemberRepository): IdentityDetailsProvider = object : IdentityDetailsProvider { override val detailsType = LibraryIdentity::class.java // Look the user up in your own data, keyed by the provider's id. override suspend fun provide(context: IdentityProviderContext): IdentityDetails { val member = members.bySubject(context.id) ?: return IdentityDetails(isUserAuthorized = false, details = LibraryIdentity("", "", "")) return IdentityDetails( isUserAuthorized = true, details = LibraryIdentity(member.id, member.role, member.name) ) } } ``` * TypeScript (preview) ```typescript import { ArcApplication, currentServices, type IdentityDetailsProvider } from '@cratis/arc.core'; import { z } from 'zod'; const libraryIdentity = z.object({ memberId: z.string(), role: z.string(), displayName: z.string() }); const identityDetails: IdentityDetailsProvider = { schema: libraryIdentity, provide: async principal => { // Look the user up in your own data, keyed by the principal's id. const members = await currentServices().resolve(MemberRepository); const member = await members.bySubject(principal.id); if (!member) return undefined; // /.cratis/me answers 403 return { memberId: member.id, role: member.role, displayName: member.name }; } }; const builder = ArcApplication.createBuilder({ identityDetails }); builder.services.addScoped(MemberRepository, MongoMemberRepository); ``` ``` no yes Authenticated principal → identity context IProvideIdentityDetails.Provide() IsUserAuthorized? /.cratis/me returns 403 IdentityDetails — your domain identity /.cratis/me response Frontend — typed identity ``` Two things fall out of this design: * **Identity-details rejection.** `IsUserAuthorized: false` makes `/.cratis/me` return `403` for that result. It is **not a global operation gate**: the command/query authorization evaluator checks the principal and operation attributes independently. Protect actual operations against blocked users or expired subscriptions; hiding the UI or rejecting identity enrichment is insufficient. * **One identity, backend to browser.** The details you return are served from `/.cratis/me` and consumed, typed, on the React side — the same way commands and queries are. You enrich identity once and read it everywhere. (Details: [Identity](/arc/backend/csharp/identity/), [contracts](/arc/backend/csharp/identity/contracts/), and [on the frontend](/arc/frontend/react/identity/).) Details are not authorization claims A `LibraryIdentity.Role` property is display/domain data; it does not add `ClaimTypes.Role` or grant `[Roles("Librarian")]`. The identity the frontend reads supplies client context, not authoritative proof of permission. Establish roles in the authenticated principal using trusted authentication/claims configuration. Enforce blocked-user, subscription, and membership restrictions on the server’s actual operations as well as reflecting them in the UI. ## What can they do — authorize commands and queries Gate *what they can do* with attributes on the command or query. This standalone MongoDB excerpt uses the [tutorial’s domain types and provider setup](/arc/backend/csharp/getting-started/your-first-command/) plus `Cratis.Arc.Authorization` imports. `LibraryRole` is an application enum that names roles for `nameof`, which Arc’s [ARC0011](/arc/backend/csharp/code-analysis/#arc0011-role-names) analyzer prefers over string literals: * C# ```csharp public enum LibraryRole { Librarian } // the application's role names, in one place [Roles(nameof(LibraryRole.Librarian))] // only a Librarian may register an author [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } [ReadModel] public record Author(AuthorId Id, AuthorName Name) { [AllowAnonymous] // the public catalog is open to everyone public static ISubject> AllAuthors(IMongoCollection c) => c.Observe(); } ``` * Java ```java @Roles("Librarian") // only a Librarian may register an author @Command public record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); } } @ReadModel public record Author(AuthorId id, AuthorName name) { @AllowAnonymous // the public catalog is open to everyone public static Flow.Publisher> allAuthors(@FromServices AuthorRepository authors) { return authors.observeAll(); } } ``` * Kotlin ```kotlin @Roles("Librarian") // only a Librarian may register an author @Command class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) } } @ReadModel data class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic @AllowAnonymous // the public catalog is open to everyone fun allAuthors(@FromServices authors: AuthorRepository): Flow> = authors.observeAll() } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { allowAnonymous, command, inject, query, readModel, roles, service, type ObservableSource } from '@cratis/arc.core'; @roles('Librarian') // only a Librarian may register an author @command() export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @inject(AuthorRepository) handle(authors: AuthorRepository): Promise { return authors.save({ id: this.id, name: this.name }); } } @readModel() export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @allowAnonymous() // the public catalog is open to everyone @query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource { return authors.observeAll(); } } ``` - `[Authorize]` requires an authenticated user; `[Roles("Librarian", "Admin")]` requires **at least one** of the listed roles; `[AllowAnonymous]` opens a specific endpoint back up. - Apply them at the **class level** to cover everything and **override per method** for exceptions — secure by default, opt out where you mean it. - A failure is automatic: `401` if authentication stops the request before Arc runs, `403` if Arc’s command or query pipeline rejects the signed-in user. Pipeline authorization failures are surfaced in the generated command/query result as `isAuthorized: false`, so the UI can react. When a rule depends on a specific row — “you can only edit your own profile” — enforce it server-side against the trusted principal and that row, using your authorization extension or an explicit operation guard. An ordinary validation rejection is not the same result as an authorization rejection. (Full matrix, claims access, and inheritance rules: [Authorization](/arc/backend/csharp/core/authorization/).) ## Whose data — multi-tenancy If one deployment serves many customers, *who* and *what* aren’t enough — you need *whose*. Arc treats the **tenant** as a first-class part of every request: it resolves a tenant id, keeps it in context for the whole request, and points tenant-aware stores at the right data. The shape of it: 1. A **resolver selects a tenant id** from a claim, header, or other configured source. A header resolver simply reads the value; running it after authentication does **not** prove membership. 2. Your trusted ingress or application **authorizes the user’s membership** in the selected tenant before serving tenant data. The id is then available through tenant context. 3. Configure and verify **tenant-aware storage** separately — database routing, partitions, or application query filters. Tenant selection alone neither isolates data nor proves every command/query uses the selected store. Test both cross-tenant membership rejection and storage separation. Resolve the tenant after you authenticate Tenant id is sensitive. Resolve it from authenticated claims, validate membership, and never trust a raw header or query parameter — otherwise tenant isolation is one spoofed value away from breaking. In production, [Cratis AuthProxy](/authproxy/) can take this on at the edge: it strips spoofable identity and tenant headers from every inbound request, resolves the tenant after the user signs in, and validates it before the request reaches your app. Set it up with [tenant resolvers](/arc/backend/csharp/tenancy/resolvers/), reach it through [tenant context](/arc/backend/csharp/tenancy/tenant-context/), and isolate storage with [database resolvers](/arc/backend/csharp/tenancy/database-resolvers/) — the [tenancy overview](/arc/backend/csharp/tenancy/) ties them together. ## Where to go next * [Identity](/arc/backend/csharp/identity/) — the provider flow, contracts, and multi-service topologies in full. * [Authorization](/arc/backend/csharp/core/authorization/) — every attribute, the inheritance rules, and claims-based custom logic. * [Tenancy](/arc/backend/csharp/tenancy/) — resolvers, context, and per-tenant data isolation. * [Microsoft Identity headers](/arc/backend/csharp/asp-net-core/microsoft-identity/) — accepting the `x-ms-client-principal` family of HTTP headers from your ingress. These are unsigned assertions accepted from a **trusted ingress**, not bearer tokens validated by Arc. The ingress must replace spoofable headers and prevent direct backend access, and the host has to opt in with `TrustForwardedIdentityHeaders` before Arc trusts them. For local-only fixtures, see [Generate a principal](/arc/backend/csharp/development/generating-principal/). # Understanding the proxy boundary > How generated proxies keep an Arc backend and React frontend in step, with a C# example and links to JVM and TypeScript generator setup. Every full-stack app has a seam down the middle: a backend on one side, TypeScript on the other, HTTP in between. Normally *you* are the glue across that seam — you write a controller, hand-write a DTO, redeclare its shape in TypeScript, wrap it in a `fetch`, and then spend the rest of the project keeping both sides in agreement. Nothing tells you when they drift: a renamed field compiles happily on each side and fails in the browser at runtime. Arc connects that seam. You declare the command or query once on the backend and generate the TypeScript your frontend calls. There’s no second declaration to keep in sync, because there’s no second declaration at all. This page is about *why that works* and *what keeps it honest* — the idea at the center of everything Cratis calls “full-stack type safety.” ## One source of truth The thing to internalize: **the backend declaration is the contract.** The TypeScript proxy isn’t a parallel definition you maintain — it is generated from that declaration. In the C# example below, the `RegisterAuthor` record defines the shape; JVM and TypeScript backends use their own declarations and generators. ``` Debug build · post-build assembly/PDB tool import + call HTTP — typed both ends C# record — [Command] RegisterAuthor generated TS proxy — RegisterAuthor React — CommandDialog · .use() ``` Generating from the same types catches structural drift at compile time when you rebuild and type-check both sides. It does not prove that a deployed client matches a deployed server, that runtime serialization customizations agree, or that build-time routes match runtime configuration. Those still need integration checks. ## Walk the boundary 1. **Write the slice on the backend.** A command is a record (C#), a data class (Kotlin), or a decorated class (TypeScript) with a handler on it. This standalone excerpt uses the [tutorial’s domain types, imports, and provider setup](/arc/backend/csharp/getting-started/your-first-command/): * C# ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName Name) { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, Name)); } ``` * Java ```java @Command public record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); } } ``` * Kotlin ```kotlin @Command data class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; @command() export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @inject(AuthorRepository) handle(authors: AuthorRepository): Promise { return authors.save({ id: this.id, name: this.name }); } } ``` 2. **Configure and generate.** Each backend discovers commands, queries, referenced types and supported validation rules from its own declarations. Generation is not an automatic step of every build; configure and run the appropriate backend tool. On **C#**, install `Cratis.Arc.ProxyGenerator.Build` and set `CratisProxiesOutputPath` as in [generator setup](/arc/backend/csharp/proxy-generation/getting-started/), then run `dotnet build -c Debug`. An MSBuild target runs after the build and invokes a **post-build executable** that inspects the compiled assembly and its PDB — it is not a Roslyn source generator. Without an output path the target does nothing, which is also how you skip generation on a second build. On **Kotlin and Java**, apply the `io.cratis.arc` Gradle plugin and configure `cratisArc { proxies { } }` as in [TypeScript proxies](/arc/backend/kotlin/guides/typescript-proxies/). A **KSP annotation processor** runs as part of compilation and records what it found in an artifact manifest, and the `generateArcProxies` Gradle task turns that manifest into TypeScript. On **TypeScript**, the source-preview `arc-proxygenerator` reads backend TypeScript source with `--project` and `--artifacts`, and writes client proxies and optionally server metadata. Run your application’s generation script when declarations change; see [TypeScript generator setup](/arc/backend/typescript/proxy-generation/getting-started/). The preview has its own [capability reference](/arc/backend/typescript/reference/capabilities/), not a guarantee of parity with C# or JVM. Namespace folders, source-file placement, and index output depend on each backend’s generator configuration. 3. **Import the proxy in React.** It’s already there, generated from the backend code above — call it as if it were local, typed code: ```tsx import { RegisterAuthor } from './Authors/RegisterAuthor'; // generated // Form fragment: retain the imports/providers and per-operation id state // from the first-slice tutorial; mount a new dialog instance per registration. command={RegisterAuthor} title="Add author" initialValues={{ id }}> value={i => i.name} title="Name" /> ``` ## What keeps it honest Here’s the payoff — and the whole reason the boundary is *generated* rather than written. Suppose you rename the name property on the command: * C# ```csharp [Command] public record RegisterAuthor(AuthorId Id, AuthorName FullName) // was Name { public Task Handle(IMongoCollection authors) => authors.InsertOneAsync(new Author(Id, FullName)); } ``` * Java ```java @Command public record RegisterAuthor(AuthorId id, AuthorName fullName) { // was name public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, fullName)); } } ``` * Kotlin ```kotlin @Command data class RegisterAuthor(val id: AuthorId, val fullName: AuthorName) { // was name suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, fullName)) } } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { command, inject } from '@cratis/arc.core'; @command() export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) fullName!: AuthorName; // was name @inject(AuthorRepository) handle(authors: AuthorRepository): Promise { return authors.save({ id: this.id, name: this.fullName }); } } ``` Rebuild, and the generated proxy has `fullName` where it used to have `name`. The React component that still reads the old property stops compiling — caught at build, not in production: ```tsx // This line stops compiling until you fix it: value={i => i.name} title="Name" /> // ^^^^ Property 'name' does not exist on RegisterAuthor ``` You didn’t run the app. You didn’t open the browser. After regeneration and a frontend type-check, the TypeScript compiler catches the drift, because the accessor `i => i.name` points at a property the regenerated type no longer has. That’s the difference between a generated boundary and a hand-written one: drift is a **compile error**, not a **production incident**. The read side is just as honest Rename a field on a `[ReadModel]`, rebuild, and every component reading it through the generated query proxy stops compiling the same way. The contract holds in both directions — commands you send and data you read. ## What gets generated A configured generator build can emit: * **Commands** — a class you instantiate and execute (or hand to a `CommandDialog`), with every parameter — route, query string, body — flattened into typed properties. * **Queries** — a proxy exposing a React `.use()` hook that returns the typed result with its loading and error state; **observable** queries return a live subscription that re-renders on change — no polling, no manual refresh. * **Types and enums** — every complex type and enum your commands and queries reference, so nested shapes are typed too. * **Identity details** — your custom identity type, so the signed-in user is typed on the frontend. Each has a detailed reference: [Commands](/arc/backend/csharp/proxy-generation/commands/), [Queries](/arc/backend/csharp/proxy-generation/queries/), and [Identity details](/arc/backend/csharp/proxy-generation/identity-details/). The [frontend proxy-generation guide](/arc/frontend/react/proxy-generation/) shows the generated TypeScript for each, with the `.use()` and `useSuspense()` hooks. Not every backend return value crosses the boundary On the C# backend, [command operations](/arc/backend/csharp/commands/operations/) describe inline work for the server, not the browser. The proxy generator excludes operations and operation batches from the client response contract. React still executes the generated command and handles its ordinary result; compensation and recovery observations remain backend concerns. ## The transport disappears too Types are only half of what the proxy absorbs. The other half is **how the call travels**. Your component never writes a URL, picks an HTTP verb, or opens a socket — it executes `RegisterAuthor` or renders `AllAuthors.use()`, and the proxy owns the wire. That makes the transport an implementation detail behind the boundary: today the calls ride HTTP, and if Arc carried them over something else — gRPC-Web, say — the proxy is where that would change, not your components. The query system already cashes in on this freedom. A regular query executes as a plain HTTP request. An observable query streams — over WebSockets or Server-Sent Events, a choice you make once with the `queryTransportMethod` prop on the [`` context component](/arc/frontend/react/arc/), not in any component. And the same observable query can still be executed as a one-shot HTTP fetch by calling `perform()` on the proxy, when you want a snapshot instead of a subscription: ```tsx const [authors] = AllAuthors.use(); // live — streams over WebSockets or SSE const snapshot = await new AllAuthors().perform(); // one-shot — a plain HTTP fetch, same proxy ``` One backend declaration, one generated frontend proxy — and whether the data arrives as a response or a stream is the proxy’s concern, not yours. Tune the live transport in the [query configuration reference](/arc/frontend/react/queries/configuration/). ## It’s a build step, not a runtime discovery call A few things follow from the proxies being *generated*, and they’re worth holding onto: * **You never edit a generated file.** Change the backend declaration and regenerate; TypeScript’s source preview may commit generated proxies alongside the source. * **On C#, the generator tracks what it made** and removes files for commands or queries you delete. See [File index tracking](/arc/backend/csharp/proxy-generation/file-index-tracking/); the TypeScript generator also limits stale deletion to files it owns. * **The sequencing matters:** generate proxies from updated backend declarations, then type-check the frontend. C# requires a successful Debug build first; the TypeScript generator reads source with the project configuration. ## Why this is the thing that matters Plenty of frameworks give you CQRS, or an event store, or a component library. The proxy boundary is what makes Cratis **full-stack**: the type safety doesn’t stop at the edge of the backend — it runs all the way to the React component. It’s why a Cratis feature can cross that boundary without hand-maintaining two declarations. Regeneration and frontend type-checking catch structural drift; deployed versions still need integration checks. Ready to see it run? [Build your first command and query](/arc/backend/csharp/getting-started/your-first-command/), then [wire it into React](/arc/frontend/react/getting-started/) — and watch the same property name flow from C# all the way to the screen. # Upgrading Arc > Changes in Arc that can need an action from you when you upgrade, and what to do about each one. Most Arc upgrades need nothing from you. When a release changes a default or a behavior that an application can rely on, it gets a page here. Each page answers the question that matters most first: **does anything change for you?** It then names who is affected and the one change that keeps the previous behavior when you need it. | Change | What it means for you | | --- | --- | | [Secure defaults](/arc/upgrading/secure-defaults/) | Forwarded identity headers need an explicit opt-in before Arc trusts them, and discovery endpoints require authentication outside Development. | # Migrate generated identifiers > Move identifier generation out of Handle and into generated Handle parameters, and replace identity-source queues with named scenario pins. A handler that calls `CartId.New()` inside `Handle()` hides the identifier from your specs: nothing in a spec can choose it. Moving it to a command property would let every client choose it instead. The way out is a `[GeneratedValue]` Handle parameter that a spec pins by name. This guide assumes an Arc command and an identifier that implements `IGeneratable` from Cratis.Fundamentals 7.23.0 or later. See [declaring a server-owned identifier](/arc/backend/csharp/commands/generated-values/#declare-a-server-owned-identifier) for the `CartId` declaration used below. The before and after code is the `StartCartWithInlineGeneration` and `StartCart` pair in the [Generated Values Shop sample](https://github.com/Cratis/Samples/tree/main/GeneratedValues/Shop). ## See the diagnostic The command creates its identity inline: Shop/Carts/Starting/StartCartWithInlineGeneration.cs ```csharp using Cratis.Arc.Commands.ModelBound; namespace Shop.Carts.Starting; [Command] public record StartCartWithInlineGeneration { public CartId Handle() => CartId.New(); } ``` [ARC0028](/arc/backend/csharp/code-analysis/arc0028/) reports the call. Its default severity is Info, and a build that raises it shows a warning like this one from the sample: ```text Shop/Carts/Starting/StartCartWithInlineGeneration.cs(22,31): warning ARC0028: Move inline generation of 'CartId' to a [GeneratedValue] Handle parameter so scenarios can pin it ``` ## Apply the quick fix In your editor, choose **Move to a \[GeneratedValue] parameter** on the diagnostic. For a supported single expression it adds the parameter and replaces the call: Shop/Carts/Starting/StartCart.cs ```csharp using Cratis.Arc.Commands.ModelBound; namespace Shop.Carts.Starting; [Command] public record StartCart { public CartId Handle([GeneratedValue("cart")] CartId cartId) => cartId; } ``` The fix derives a parameter name; this example then chose the explicit name `cart` so scenario pins survive a C# parameter rename. Arc now calls `CartId.New()`, validates the value and supplies it to `Handle()`. The value stays outside the command’s request properties. Update any direct callers of the handler yourself; the fix does not. The fix recognizes `X.New()`, `GenerateValue.*(...)` and `Guid.NewGuid()` converted or wrapped into a concept that implements a generation contract. A raw primitive cannot become a generated parameter. It does not move generation inside loops, branches or nested functions, and it does not carry over nonconstant strategy arguments. Migrate those by hand: moving a per-iteration or conditional allocation into a Handle parameter changes when and how many values are created. Review the concept’s factory too, because it must own the strategy the handler used before. Keep identifiers the client supplies, such as an existing cart to update, on command properties. They are not generated values. ## Move time-dependent strategies to the context contract If your handler read the clock to build the value, such as a time-ordered order identifier, do not carry that read into `New()`. Implement `IGeneratableInContext` with `New(GenerationContext)` instead, as the shop’s `OrderId` does with `GenerateValue.TypeId("order", context)`. Follow [context-dependent generation](/arc/backend/csharp/commands/generated-values/#generate-with-the-commands-time-and-tenant) for the full declaration. Arc passes the command’s `ReceivedAt` and the mapped tenant. If both interfaces remain on the type, Arc uses only the context form and [ARC0027](/arc/backend/csharp/code-analysis/arc0027/) warns about the plain one. Manually constructed contexts need a received time; a default time fails rather than using `UtcNow`. ## Update your specs Specs that called the handler through a scenario now need a pin, because scenarios are strict about generated parameters. Pin by the name you chose: Shop.Specs/Carts/Starting/for_StartCart/when_starting_a_cart.cs ```csharp using Shop.Carts.Starting; namespace Shop.Specs.Carts.Starting.for_StartCart; public class when_starting_a_cart : Specification { readonly CommandScenario _scenario = new(); readonly CartId _cartId = new(Guid.Parse("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee")); CommandResult _result = null!; void Establish() => _scenario.PinGeneratedValue("cart", _cartId); async Task Because() => _result = await _scenario.Execute(new StartCart()); void Destroy() => _scenario.Dispose(); [Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] void should_return_the_pinned_cart_id() => ((CommandResult)_result).Response.ShouldEqual(_cartId); } ``` What changes in specs: * An unpinned generated parameter fails the command with a `GeneratedValueNotPinned` outcome. Pin it, or call `AllowGeneration()` when the exact value is irrelevant. * Pins and generation policy must be set before the first `Execute`. * For a context-dependent concept, use `Clock` and `Tenant` as in [fix the clock and tenant](/arc/backend/csharp/testing/generated-values/#fix-the-clock-and-tenant), and configure `Clock` before the first `Execute` or `Validate`. * A fixed clock does not make a random suffix deterministic. Keep pinning the whole value when an assertion depends on it. ## Replace identity-source queues with named pins Arc v22.65.3 removed `IIdentitySource` and `CommandScenario.Generate(Guid[])`. Remove the identity-source dependency and its calls from your handler and use a generated parameter. If your identifier does not yet implement `IGeneratable`, add that interface and its static `New()` strategy. Replace queued raw Guids in specs with typed, named pins. With `AllocateCartId` and `CartId` from the declaration linked above, this fragment goes in an async spec that imports `Cratis.Arc.Commands`, `Cratis.Arc.Testing.Commands` and `Cratis.Specifications`: * C# ```csharp using var scenario = new CommandScenario(); var cartId = CartId.New(); scenario.PinGeneratedValue("cart", cartId); var result = await scenario.Execute(new AllocateCartId()); result.ShouldBeSuccessful(); scenario.GeneratedValues.Last("cart").ShouldEqual(cartId); ``` A pin identifies the command type and the generated name, instead of relying on the order in which handlers consume a queue. It supplies the same value on every execution of that command in the scenario. Use separate scenarios when executions need different values, or `AllowGeneration()` when their exact values do not matter. ## Check concept rules on generated values Generated values, including scenario pins, now fail with `GeneratedValueRejected` if any concept rule fails, including Warning and Information rules. Previously, those severities allowed execution. Review your generation strategies and pins so they satisfy every concept rule; a rejected value is a server defect, not a request validation failure. Validation of caller-supplied input is unchanged. ## Next steps * [Pin generated values in command specs](/arc/backend/csharp/testing/generated-values/) covers multiple parameters, nested commands and reading all captures. * [Generated values](/arc/backend/csharp/commands/generated-values/) explains resolution, validation and when not to use `[GeneratedValue]`. # Migrating to secure defaults > Arc no longer trusts forwarded identity headers or exposes discovery endpoints anonymously outside Development; see who is affected and the restoring setting. Some Arc defaults used to be convenient but unsafe on a deployed host. Arc now ships safe defaults instead. Read each section below and check whether your application relies on the old behavior. Each section ends with the single change that restores it. ## Forwarded identity headers need an explicit opt-in **Who is affected:** applications that authenticate users through the unsigned `x-ms-client-principal`, `x-ms-client-principal-id` and `x-ms-client-principal-name` headers. That is every application that calls `builder.AddCratis()`, or `builder.Services.AddMicrosoftIdentityPlatformIdentityAuthentication()`, and runs behind Azure App Service or Container Apps authentication (EasyAuth) or Cratis AuthProxy. On the Arc.Core `HttpListener` host it is every application that relies on the built-in `MicrosoftIdentityPlatformAuthenticationHandler`. **What changed:** Arc used to trust these headers from any caller. Anyone who could reach the application directly could send them and act as any user. Arc now ignores them until the host opts in. Without the opt-in, these headers do not authenticate requests; endpoints that require an authenticated user return 401 unless another authentication scheme authenticates the caller. **How to tell:** an ASP.NET Core host that calls `AddMicrosoftIdentityPlatformIdentityAuthentication()` (including through `AddCratis()`) without the opt-in logs a warning that names the setting on every startup. The built-in header handlers log a warning on the first request that carries the headers while they are not trusted. **What to do:** if every request reaches your application through an ingress that strips caller-supplied identity headers and sets its own, opt in. In a C# host, add one line to the options you pass to `AddCratis` or `AddCratisArc`: * C# ```csharp builder.AddCratis(options => options.TrustForwardedIdentityHeaders = true); ``` Or in configuration, for example as the environment variable `Cratis__Arc__TrustForwardedIdentityHeaders=true`: ```json { "Cratis": { "Arc": { "TrustForwardedIdentityHeaders": true } } } ``` If the application can be reached without going through such an ingress, do not opt in: configure a real authentication scheme instead. `Cratis:Arc:Introspection:TrustForwardedIdentityHeaders` is now obsolete. Setting it still turns on the host-wide `TrustForwardedIdentityHeaders`, so a host that already set it keeps working. Move the setting to `Cratis:Arc:TrustForwardedIdentityHeaders`. Startup no longer refuses protected catalogs behind the header scheme, because the scheme authenticates nobody until the host opts in. See [Microsoft Identity](/arc/backend/csharp/asp-net-core/microsoft-identity/#trusting-the-forwarded-headers). ## Discovery endpoints require authentication outside Development **Who is affected:** applications or tools that read the discovery endpoints of a host running outside Development without signing in. The discovery endpoints are the command and query catalogs (`/.cratis/commands` and `/.cratis/queries`) and identity discovery (`/.cratis/users`, `/.cratis/tenants` and `/.cratis/identity-details/schema`). Command and query invocation and `/.cratis/me` are not affected. **What changed:** these endpoints used to be anonymous in every environment. They are now anonymous only in Development, where Lens and the Cratis CLI read them locally. Everywhere else they require an authenticated caller, so an anonymous request gets 401. A host outside Development that has no way to authenticate callers, such as an ASP.NET Core host without a default authentication scheme or an Arc.Core host without authentication handlers, does not map them at all (404) and logs a warning that names the setting below on startup. Development is decided by the host’s `IHostEnvironment.IsDevelopment()`, including environment names configured through `DOTNET_ENVIRONMENT`, command-line arguments or host options. Only when no host environment is registered does Arc fall back to `ASPNETCORE_ENVIRONMENT`. **What to do:** nothing, if only signed-in users or local tooling read these endpoints. To expose them anonymously as before, add one line to the options in a C# host that you pass to `AddCratis` or `AddCratisArc`: * C# ```csharp builder.AddCratisArc(options => options.Introspection.RequireAuthentication = false); ``` Or set `Cratis__Arc__Introspection__RequireAuthentication=false`. Outside Development the host then logs a warning on startup that the endpoints are anonymous. `Introspection.RequireAuthentication` remains a `bool` for source compatibility. Its getter returns `false` when unset, but leaving it unset selects the environment default; explicitly assigning `false` opts into anonymous discovery in every environment. `Introspection.Roles` now implies authentication on its own instead of needing `RequireAuthentication: true`. If you do not need discovery on deployed hosts, set both `Cratis:Arc:Introspection:Enabled` and `Cratis:Arc:Introspection:IdentityDiscovery` to `false`. No discovery routes, enforcement checks or exposure warnings remain; `/.cratis/me` is unchanged. See [turning off all discovery](/arc/backend/csharp/introspection/#turn-off-all-discovery-in-deployed-environments) for appsettings and environment-variable examples. See [Introspection](/arc/backend/csharp/introspection/#production-access). # Vertical slices > Why a Cratis feature keeps its backend and frontend together in one folder — and why that beats organizing code by technical layer. We strongly recommend organizing a Cratis application by **feature**, not by technical layer. Nothing in the framework enforces this — Arc discovers your commands and read models wherever they live — but we recommend it emphatically, because high cohesion and low coupling fall out of the shape almost for free. Everything for one behavior — the command that writes state, the read model and query that serve it, the React component that renders it, and the specs that prove it — lives together in a single folder. That folder is a **vertical slice**, and the slice folders inside a feature map directly onto the timeline an [event model](/event-modeling/) lays out. This page explains *why*. If you want to see one built, the [standalone Arc tutorial](/arc/tutorial/) builds a database-backed feature; the [capstone walkthrough](/build-a-full-app/) connects the wider Cratis stack. ## The pain it removes In a layer-organized codebase, one feature is smeared across the tree: ```shell ❌ Organized by layer — "register author" touches five folders Commands/RegisterAuthor.cs Handlers/RegisterAuthorHandler.cs Dtos/AuthorDto.cs Clients/AuthorsClient.ts web/src/features/authors/AddAuthor.tsx ``` To change how authors register, you open five folders, hold the whole layer cake in your head, and hope you didn’t miss a place. New contributors have to learn *every* layer before they can touch *one* feature. A vertical slice puts the whole feature in one place: ```shell ✅ Organized by feature — one folder, read top to bottom Authors/Registration/ ├── Registration.cs # command + Handle() + read model + query ├── AddAuthor.tsx # the React screen └── when_registering/ # the specs ``` You navigate by *what the software does*, not by *what kind of file it is*. ## Why the frontend and backend belong together This is the part that surprises people coming from a layered or separate-repo world. Cratis deliberately keeps the C# backend and the React frontend of a feature side by side. Three reasons: 1. **A feature is one thing, and it spans the stack.** “Register an author” is not a backend task and a frontend task — it’s *one* behavior with a write side and a read side. The command, the query, and the form are different facets of the same change. They are conceived together, they change together, and a bug usually lives in the handoff between them. Keeping them adjacent means one mental context and, often, one pull request. 2. **Full-stack type safety binds them anyway.** Arc generates the TypeScript proxies for a command or query from your C# types. The frontend literally depends on the backend’s shapes — so when you rename a property in the command, the React code stops compiling until you fix it. Co-locating makes that feedback loop tight: the thing that broke is *right next to* the thing that changed, and the compiler points you straight at it. Separating them across folders or repos hides that relationship without removing it. 3. **High cohesion, low coupling — by construction.** Everything a feature needs is in its folder (cohesion); features don’t reach into each other’s internals, they communicate through commands, queries, and shared contracts (low coupling). Layering inverts this: it scatters one feature (low cohesion) and tempts every feature to share the same fat “service” or “models” layer (high coupling). Slices keep changes local, so you can build, change, and even delete a feature without ripping through unrelated code. ``` One feature folder generated proxy writes query proxy AddAuthor.tsx RegisterAuthor.Handle() MongoDB / EF Core Author read model Authors.tsx ``` None of this dictates your repository layout, though. The proxy generator writes the TypeScript wherever you point it — the `CratisProxiesOutputPath` MSBuild property in your backend project sets the output folder, which can just as well be a separate frontend folder or another checkout entirely. If your team keeps backend and frontend apart, Arc follows your structure and the type safety holds across the distance. We co-locate because the feedback loop in point 2 is at its tightest when the generated proxy sits next to the command it mirrors — a strong preference, not a requirement. ## What lives in a slice A single backend `.cs` file can hold the backend artifacts for the behavior — the `[Command]` with its `Handle()`, validators, and the `[ReadModel]` with its query methods. Alongside it sit the React component(s) that consume the generated proxies, and the specs. Arc discovers commands, queries, and validators by convention. Host/provider setup, application-service dependencies, frontend composition, and any schema/index changes still need wiring; co-location does not do those automatically. ## Slice types Not every slice does the same job. There are four kinds — the same building blocks an event model lays out on its timeline: | Type | Purpose | | --- | --- | | **State change** | Mutates state — a command that writes to MongoDB, EF Core, or an optional event store. | | **State view** | Serves current state through a queryable read model. | | **Automation** | Runs side effects after a command or, with Chronicle, reacts to stored events. | | **Translation** | Adapts input from one slice/system by triggering a command in another. | ## The one nuance Co-location is about a *behavior*, not duplication. Things genuinely shared between slices — a [concept](/fundamentals/) like `AuthorId`, a shared read model contract, or a domain fact used by more than one feature — move up to the feature root or a shared location, not copied into every slice. The rule of thumb: if it belongs to one behavior, it lives in that slice; if it’s truly shared, lift it just high enough to be shared and no higher. ## See also * [Build a full-stack feature](/build-a-full-app/) — a slice built end to end. * [Why Arc](/arc/why-arc/) — the framework that makes slices low-ceremony. * [MediatR, MVC, and Arc](/arc/backend/csharp/coming-from-mediatr-and-mvc/) — how this differs from layered controllers/handlers. # Why Arc > The problem Arc solves, why it leans on CQRS and proxy generation, and how it keeps a full-stack app type-safe end to end. Building a modern full-stack application means making the same decisions over and over: how do commands get validated, how do queries get shaped, how does the React frontend learn the exact types the backend expects, and how do you keep all of it in sync as the code changes. Most teams answer these with a stack of libraries and a layer of hand-written glue — controllers, DTOs, fetch wrappers, validation duplicated on both sides. Arc answers them once, with conventions, so you can spend your time on behavior instead of plumbing. For Cratis, event sourcing is usually the default persistence architecture for information systems, and Chronicle is where that lives. Arc’s job is different: it gives you the CQRS boundary for information going into and out of the system, then generates the frontend contract from that boundary. CQRS and event sourcing fit together naturally, but one does not require the other. ## What Arc gives you * **Commands and queries as the unit of work.** On C#, a command is a record with a `Handle()` method; on the JVM and TypeScript it has a `handle()` method. A query can be a method on a read model. Arc maps them to HTTP automatically. * **Generated TypeScript proxies.** Every command and query becomes a typed client your React code calls. Change a command’s shape on the backend and regenerate the client; the frontend types change with it — the compiler catches the mismatch, not your users. * **Pluggable persistence.** Commands and queries use the storage you configure — for example, [Chronicle](/arc/backend/csharp/chronicle/) for event sourcing, [MongoDB](/arc/backend/csharp/mongodb/) or [EF Core / SQL](/arc/backend/csharp/entity-framework/) for current state on C#, [Spring Data](/arc/backend/kotlin/guides/spring-data/) on the JVM, or [MongoDB and Drizzle](/arc/backend/typescript/reference/capabilities/) in the TypeScript source preview. Chronicle adds its [command transaction semantics](/arc/backend/csharp/chronicle/commands/); those guarantees do not cover arbitrary external-service or database writes. * **The cross-cutting integration points.** On C#, Arc supplies validation, authorization, identity, tenant resolution, OpenAPI, and MongoDB/EF Core hooks. Check each other backend’s capability reference for its integration surface. Configure trusted authentication, membership checks, storage isolation, and business constraints for your application. ## Why CQRS and proxy generation The two ideas reinforce each other. CQRS separates the thing you *do* (a command) from the thing you *see* (a query/read model), which keeps each side simple and independently optimizable. Proxy generation then makes that separation safe across the network: because the client is generated from the backend’s own declarations, there is no second source of truth to drift. CQRS is often associated with event sourcing because events are a natural write-side model and projections are a natural read-side model. But CQRS is not event sourcing. Arc can put that boundary over current-state storage or optional Chronicle; Chronicle can store and process events without Arc. Integrations differ by backend. ``` generated proxy writes query proxy React (Components) Command handler Current-state store Read model ``` That diagram is the direct-database setup, useful for bounded current-state slices and for learning Arc in isolation. In the full Cratis loop, the [Chronicle integration](/arc/backend/csharp/chronicle/add-event-sourcing/) adds events and projections. Preserving the public query contract can keep the React caller unchanged, but provider code and data migration remain explicit work. ## Vertical slices, not layers Arc doesn’t impose a folder structure, but it’s built to make one shine: organize the application by **feature**, with a folder per slice inside it — the shape [event modeling](/event-modeling/) gives you. Everything for one behavior — the command, the read model and query it serves, the React component that renders it, and the specs that prove it — then lives in one folder, and you read a feature top to bottom instead of hunting across `Commands/`, `Handlers/`, `DTOs`, and `Clients/`. ## Where to start * Build your first feature with [C#](/arc/backend/csharp/getting-started/), [Kotlin or Java](/arc/backend/kotlin/get-started/), or the [TypeScript source preview](/arc/backend/typescript/getting-started/). * Walk the full database-backed path in the [Arc tutorial](/arc/tutorial/). * Need the wider stack? [Why developers choose Cratis](/why-cratis/) shows how Arc, Chronicle, Components, and the tools fit together. # Cratis Architecture Documentation > Cratis Architecture is a set of Roslyn analyzers that turn Cratis architectural conventions into build-time diagnostics for .NET codebases. Cratis Architecture is a set of Roslyn analyzers that turn Cratis architectural conventions into build-time diagnostics for .NET codebases. This documentation covers the analyzer package and its rule set. * Start with the [code analysis overview](/architecture/codeanalysis/) * Browse the complete [rule reference](/architecture/codeanalysis/rules/) # Code Analysis > Cratis Architecture code analysis: the Roslyn analyzer goals and a table of all CRARCH rules with their severities. Cratis Architecture ships Roslyn analyzers that enforce architectural and coding conventions across Cratis codebases. ## Goals * Keep architectural intent enforceable * Surface violations as early as possible * Give developers and agents clear, actionable feedback ## Rules Overview | Rule ID | Title | Severity | Description | | --- | --- | --- | --- | | [CRARCH0001](/architecture/codeanalysis/rules/crarch0001-exception-type-naming/) | Exception type naming | Warning | Exception types must use domain terminology and avoid the generic Exception suffix | | [CRARCH0002](/architecture/codeanalysis/rules/crarch0002-no-built-in-exception-types/) | No built-in exception types | Warning | Throwing framework exceptions hides domain intent | | [CRARCH0003](/architecture/codeanalysis/rules/crarch0003-no-postfixes-on-class-names/) | No postfixes on class names | Warning | Class names must describe domain concepts, not technical roles | | [CRARCH0004](/architecture/codeanalysis/rules/crarch0004-no-features-in-namespace/) | No Features in namespace | Warning | Namespace paths should stay domain-oriented and avoid framework-driven structure names | | [CRARCH0005](/architecture/codeanalysis/rules/crarch0005-no-regions/) | No regions | Warning | Region directives usually indicate files that need better separation of responsibilities | | [CRARCH0006](/architecture/codeanalysis/rules/crarch0006-logging-via-loggermessage/) | Logging via LoggerMessage | Warning | Structured, source-generated logging ensures consistency and better performance | | [CRARCH0007](/architecture/codeanalysis/rules/crarch0007-no-iserviceprovider-injection/) | No IServiceProvider injection | Warning | Service locator patterns hide dependencies and make code harder to reason about | | [CRARCH0008](/architecture/codeanalysis/rules/crarch0008-use-is-null-checks/) | Use is null checks | Warning | Pattern matching null checks are the preferred and consistent style | | [CRARCH0009](/architecture/codeanalysis/rules/crarch0009-use-string-interpolation/) | Use string interpolation | Warning | Interpolated strings are clearer and easier to maintain | | [CRARCH0010](/architecture/codeanalysis/rules/crarch0010-constructor-fan-out/) | Constructor fan-out | Warning | Too many dependencies indicate excessive responsibility in one type | | [CRARCH0011](/architecture/codeanalysis/rules/crarch0011-file-length-threshold/) | File length threshold | Warning | Very large files are difficult to understand and maintain | | [CRARCH0012](/architecture/codeanalysis/rules/crarch0012-async-void-forbidden/) | async void forbidden | Error | async void methods hide failures and cannot be awaited in regular flows | | [CRARCH0013](/architecture/codeanalysis/rules/crarch0013-no-blocking-on-async/) | No blocking on async | Warning | Blocking asynchronous calls can cause deadlocks and reliability issues | | [CRARCH0014](/architecture/codeanalysis/rules/crarch0014-no-test-types-in-production/) | No test types in production | Error | Production assemblies must remain independent of test-only infrastructure | | [CRARCH0015](/architecture/codeanalysis/rules/crarch0015-static-class-naming-convention/) | Static class naming convention | Warning | Static utility types follow strict naming conventions to improve discoverability | | [CRARCH0016](/architecture/codeanalysis/rules/crarch0016-unused-interfaces/) | Unused interfaces | Warning | Speculative interfaces without implementations add unnecessary abstraction | | [CRARCH0017](/architecture/codeanalysis/rules/crarch0017-namespace-must-align-with-folder-path/) | Namespace must align with folder path | Warning | Namespace and folder alignment improves navigability and consistency | | [CRARCH0018](/architecture/codeanalysis/rules/crarch0018-avoid-concrete-type-injection/) | Avoid concrete type injection | Warning | Constructor dependencies should favor abstractions for loose coupling | | [CRARCH0019](/architecture/codeanalysis/rules/crarch0019-avoid-async-postfix-on-method-names/) | Avoid Async postfix on method names | Warning | Method names should avoid unnecessary suffixes unless sync/async pairs exist | | [CRARCH0020](/architecture/codeanalysis/rules/crarch0020-handle-asynchronous-calls/) | Handle asynchronous calls | Warning | Fire-and-forget calls can hide failures and produce nondeterministic behavior | | [CRARCH0021](/architecture/codeanalysis/rules/crarch0021-serializable-attribute-not-allowed/) | Serializable attribute not allowed | Warning | Legacy serialization attributes are not part of modern Cratis architecture guidance | | [CRARCH0022](/architecture/codeanalysis/rules/crarch0022-private-modifier-not-allowed/) | Private modifier not allowed | Warning | Private is implicit in C#, so explicit modifiers add noise | | [CRARCH0023](/architecture/codeanalysis/rules/crarch0023-use-typed-logger-category/) | Use typed logger category | Warning | Typed logger categories align log events with the producing type | | [CRARCH0024](/architecture/codeanalysis/rules/crarch0024-loggermessage-container-conventions/) | LoggerMessage container conventions | Warning | LoggerMessage methods must live in convention-based containers for consistency | | [CRARCH0025](/architecture/codeanalysis/rules/crarch0025-use-cratis-fundamentals-traces/) | Use Cratis Fundamentals traces | Warning | Tracing should flow through Cratis Fundamentals abstractions for consistency | | [CRARCH0026](/architecture/codeanalysis/rules/crarch0026-use-cratis-fundamentals-metrics/) | Use Cratis Fundamentals metrics | Warning | Metrics should use Cratis Fundamentals abstractions instead of raw instrument creation | # Rule Reference > Reference of every CRARCH diagnostic in Cratis.Architecture.CodeAnalysis, with its title, severity and what it enforces. This reference lists all diagnostics in `Cratis.Architecture.CodeAnalysis`. | Rule | Title | Severity | Description | | --- | --- | --- | --- | | [CRARCH0001](/architecture/codeanalysis/rules/crarch0001-exception-type-naming/) | Exception type naming | Warning | Exception types must use domain terminology and avoid the generic Exception suffix | | [CRARCH0002](/architecture/codeanalysis/rules/crarch0002-no-built-in-exception-types/) | No built-in exception types | Warning | Throwing framework exceptions hides domain intent | | [CRARCH0003](/architecture/codeanalysis/rules/crarch0003-no-postfixes-on-class-names/) | No postfixes on class names | Warning | Class names must describe domain concepts, not technical roles | | [CRARCH0004](/architecture/codeanalysis/rules/crarch0004-no-features-in-namespace/) | No Features in namespace | Warning | Namespace paths should stay domain-oriented and avoid framework-driven structure names | | [CRARCH0005](/architecture/codeanalysis/rules/crarch0005-no-regions/) | No regions | Warning | Region directives usually indicate files that need better separation of responsibilities | | [CRARCH0006](/architecture/codeanalysis/rules/crarch0006-logging-via-loggermessage/) | Logging via LoggerMessage | Warning | Structured, source-generated logging ensures consistency and better performance | | [CRARCH0007](/architecture/codeanalysis/rules/crarch0007-no-iserviceprovider-injection/) | No IServiceProvider injection | Warning | Service locator patterns hide dependencies and make code harder to reason about | | [CRARCH0008](/architecture/codeanalysis/rules/crarch0008-use-is-null-checks/) | Use is null checks | Warning | Pattern matching null checks are the preferred and consistent style | | [CRARCH0009](/architecture/codeanalysis/rules/crarch0009-use-string-interpolation/) | Use string interpolation | Warning | Interpolated strings are clearer and easier to maintain | | [CRARCH0010](/architecture/codeanalysis/rules/crarch0010-constructor-fan-out/) | Constructor fan-out | Warning | Too many dependencies indicate excessive responsibility in one type | | [CRARCH0011](/architecture/codeanalysis/rules/crarch0011-file-length-threshold/) | File length threshold | Warning | Very large files are difficult to understand and maintain | | [CRARCH0012](/architecture/codeanalysis/rules/crarch0012-async-void-forbidden/) | async void forbidden | Error | async void methods hide failures and cannot be awaited in regular flows | | [CRARCH0013](/architecture/codeanalysis/rules/crarch0013-no-blocking-on-async/) | No blocking on async | Warning | Blocking asynchronous calls can cause deadlocks and reliability issues | | [CRARCH0014](/architecture/codeanalysis/rules/crarch0014-no-test-types-in-production/) | No test types in production | Error | Production assemblies must remain independent of test-only infrastructure | | [CRARCH0015](/architecture/codeanalysis/rules/crarch0015-static-class-naming-convention/) | Static class naming convention | Warning | Static utility types follow strict naming conventions to improve discoverability | | [CRARCH0016](/architecture/codeanalysis/rules/crarch0016-unused-interfaces/) | Unused interfaces | Warning | Speculative interfaces without implementations add unnecessary abstraction | | [CRARCH0017](/architecture/codeanalysis/rules/crarch0017-namespace-must-align-with-folder-path/) | Namespace must align with folder path | Warning | Namespace and folder alignment improves navigability and consistency | | [CRARCH0018](/architecture/codeanalysis/rules/crarch0018-avoid-concrete-type-injection/) | Avoid concrete type injection | Warning | Constructor dependencies should favor abstractions for loose coupling | | [CRARCH0019](/architecture/codeanalysis/rules/crarch0019-avoid-async-postfix-on-method-names/) | Avoid Async postfix on method names | Warning | Method names should avoid unnecessary suffixes unless sync/async pairs exist | | [CRARCH0020](/architecture/codeanalysis/rules/crarch0020-handle-asynchronous-calls/) | Handle asynchronous calls | Warning | Fire-and-forget calls can hide failures and produce nondeterministic behavior | | [CRARCH0021](/architecture/codeanalysis/rules/crarch0021-serializable-attribute-not-allowed/) | Serializable attribute not allowed | Warning | Legacy serialization attributes are not part of modern Cratis architecture guidance | | [CRARCH0022](/architecture/codeanalysis/rules/crarch0022-private-modifier-not-allowed/) | Private modifier not allowed | Warning | Private is implicit in C#, so explicit modifiers add noise | | [CRARCH0023](/architecture/codeanalysis/rules/crarch0023-use-typed-logger-category/) | Use typed logger category | Warning | Typed logger categories align log events with the producing type | | [CRARCH0024](/architecture/codeanalysis/rules/crarch0024-loggermessage-container-conventions/) | LoggerMessage container conventions | Warning | LoggerMessage methods must live in convention-based containers for consistency | | [CRARCH0025](/architecture/codeanalysis/rules/crarch0025-use-cratis-fundamentals-traces/) | Use Cratis Fundamentals traces | Warning | Tracing should flow through Cratis Fundamentals abstractions for consistency | | [CRARCH0026](/architecture/codeanalysis/rules/crarch0026-use-cratis-fundamentals-metrics/) | Use Cratis Fundamentals metrics | Warning | Metrics should use Cratis Fundamentals abstractions instead of raw instrument creation | # CRARCH0001 Exception type naming > CRARCH0001 requires exception types to use domain terminology and not end with the generic Exception suffix. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0001` ## What it checks Exception types must use domain terminology and avoid the generic Exception suffix. ## Diagnostic message `Exception type '{0}' must not end with 'Exception'` ## How to resolve Rename the exception type to a domain-focused name, such as `AuthorNotFound` instead of `AuthorNotFoundException`. # CRARCH0002 No built-in exception types > CRARCH0002 flags throwing built-in framework exception types, which hide domain intent, and asks for domain exceptions. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0002` ## What it checks Throwing framework exceptions hides domain intent. ## Diagnostic message `Throw custom domain exceptions instead of '{0}'` ## How to resolve Replace built-in exceptions with custom domain exceptions that describe the business error. # CRARCH0003 No postfixes on class names > CRARCH0003 requires class names to describe domain concepts rather than technical roles through postfixes. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0003` ## What it checks Class names must describe domain concepts, not technical roles. ## Diagnostic message `Class '{0}' must not end with postfix '{1}'` ## How to resolve Remove postfixes like `Async`, `Impl`, `Manager`, `Helper`, and `Service` from class names. # CRARCH0004 No Features in namespace > CRARCH0004 keeps namespace paths domain-oriented by flagging any namespace that contains a Features segment. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0004` ## What it checks Namespace paths should stay domain-oriented and avoid framework-driven structure names. ## Diagnostic message `Namespace '{0}' must not contain '.Features.'` ## How to resolve Remove the `.Features.` segment from the namespace and align it with the domain path. # CRARCH0005 No regions > CRARCH0005 flags #region directives, which usually mean a file needs better separation of responsibilities. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0005` ## What it checks Region directives usually indicate files that need better separation of responsibilities. ## Diagnostic message `Avoid #region directives` ## How to resolve Split large types or methods into focused units instead of organizing with `#region`. # CRARCH0006 Logging via LoggerMessage > CRARCH0006 requires logging through source-generated LoggerMessage methods for consistent, structured and faster log events. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0006` ## What it checks Structured, source-generated logging ensures consistency and better performance. ## Diagnostic message `Use [LoggerMessage] generated methods instead of direct ILogger.Log* calls` ## How to resolve Define and use `[LoggerMessage]` methods in `*LogMessages` classes instead of direct log calls. # CRARCH0007 No IServiceProvider injection > CRARCH0007 flags IServiceProvider injection, because the service locator pattern hides dependencies and hampers reasoning. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0007` ## What it checks Service locator patterns hide dependencies and make code harder to reason about. ## Diagnostic message `Do not inject IServiceProvider; inject specific interfaces` ## How to resolve Inject explicit interfaces in constructors instead of `IServiceProvider`. # CRARCH0008 Use is null checks > CRARCH0008 prefers pattern-matching null checks with is null over equality operators, for one consistent style. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0008` ## What it checks Pattern matching null checks are the preferred and consistent style. ## Diagnostic message `Use 'is null'/'is not null' instead of '== null'/'!= null'` ## How to resolve Rewrite null checks to use `is null` and `is not null`. # CRARCH0009 Use string interpolation > CRARCH0009 prefers interpolated strings over concatenation or formatting calls because they are clearer and easier to maintain. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0009` ## What it checks Interpolated strings are clearer and easier to maintain. ## Diagnostic message `Use string interpolation instead of string.Format or concatenation` ## How to resolve Replace `string.Format` and string concatenation with interpolated strings. # CRARCH0010 Constructor fan-out > CRARCH0010 flags constructor fan-out: a type with too many dependencies is carrying too much responsibility. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0010` ## What it checks Too many dependencies indicate excessive responsibility in one type. ## Diagnostic message `Constructor has {0} dependencies (maximum is 7)` ## How to resolve Refactor to reduce constructor dependencies to seven or fewer. # CRARCH0011 File length threshold > CRARCH0011 flags files above a length threshold, because very large files are difficult to understand and maintain. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0011` ## What it checks Very large files are difficult to understand and maintain. ## Diagnostic message `File has {0} effective lines (maximum is 400)` ## How to resolve Split the file into smaller focused types or helper abstractions once the threshold is exceeded. # CRARCH0012 async void forbidden > CRARCH0012 forbids async void methods, which hide failures and cannot be awaited in regular flows; it reports an error. * **Category:** Architecture * **Default severity:** Error * **Diagnostic ID:** `CRARCH0012` ## What it checks `async void` methods hide failures and cannot be awaited in regular flows. ## Diagnostic message `Avoid async void methods outside event handlers` ## How to resolve Change `async void` methods to `async Task` unless the method is an event handler. # CRARCH0013 No blocking on async > CRARCH0013 flags blocking on asynchronous calls, which can cause deadlocks and reliability issues. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0013` ## What it checks Blocking asynchronous calls can cause deadlocks and reliability issues. ## Diagnostic message `Avoid blocking async calls via .Result, .Wait(), or .GetAwaiter().GetResult()` ## How to resolve Use `await` instead of blocking APIs on tasks. # CRARCH0014 No test types in production > CRARCH0014 reports an error when production assemblies depend on test-only types or infrastructure. * **Category:** Architecture * **Default severity:** Error * **Diagnostic ID:** `CRARCH0014` ## What it checks Production assemblies must remain independent of test-only infrastructure. ## Diagnostic message `Production code must not reference testing/specification types: '{0}'` ## How to resolve Remove references to testing and specification types from production code. # CRARCH0015 Static class naming convention > CRARCH0015 requires static utility classes to follow strict naming conventions that improve discoverability. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0015` ## What it checks Static utility types follow strict naming conventions to improve discoverability. ## Diagnostic message `Static class '{0}' must end with one of: {1}` ## How to resolve Rename static classes to end with `Extensions`, `Converters`, `Ids`, `WellKnown`, or `Defaults`. # CRARCH0016 Unused interfaces > CRARCH0016 flags interfaces without implementations, because speculative abstractions add needless complexity. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0016` ## What it checks Speculative interfaces without implementations add unnecessary abstraction. ## Diagnostic message `Interface '{0}' has no concrete implementations` ## How to resolve Remove unused interfaces or provide concrete implementations where needed. # CRARCH0017 Namespace must align with folder path > CRARCH0017 requires a type's namespace to align with its folder path, improving navigability and consistency. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0017` ## What it checks Namespace and folder alignment improves navigability and consistency. ## Diagnostic message `Namespace '{0}' should align with folder path '{1}'` ## How to resolve Update the namespace or move the file so namespace segments match folder structure. # CRARCH0018 Avoid concrete type injection > CRARCH0018 asks constructors to depend on abstractions rather than concrete types, for looser coupling. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0018` ## What it checks Constructor dependencies should favor abstractions for loose coupling. ## Diagnostic message `Constructor dependency '{0}' should be an interface abstraction` ## How to resolve Inject interfaces instead of concrete classes. Types marked with `[ReadModel]` are exempt. # CRARCH0019 Avoid Async postfix on method names > CRARCH0019 flags the Async postfix on method names unless a synchronous and asynchronous pair makes it necessary. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0019` ## What it checks Method names should avoid unnecessary suffixes unless sync/async pairs exist. ## Diagnostic message `Method '{0}' should not end with 'Async' unless a synchronous '{1}' method also exists` ## How to resolve Rename methods to remove the `Async` suffix unless a synchronous counterpart exists. # CRARCH0020 Handle asynchronous calls > CRARCH0020 flags unhandled asynchronous calls, because fire-and-forget calls hide failures and give nondeterministic behavior. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0020` ## What it checks Fire-and-forget calls can hide failures and produce nondeterministic behavior. ## Diagnostic message `Asynchronous call '{0}' must be handled by awaiting it or chaining a continuation` ## How to resolve Await task-returning calls or explicitly chain a continuation. # CRARCH0021 Serializable attribute not allowed > CRARCH0021 flags the Serializable attribute, since legacy serialization attributes are not part of Cratis architecture guidance. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0021` ## What it checks Legacy serialization attributes are not part of modern Cratis architecture guidance. ## Diagnostic message `Type '{0}' must not be marked with [Serializable]` ## How to resolve Remove `[Serializable]` from types. # CRARCH0022 Private modifier not allowed > CRARCH0022 flags the explicit private modifier, because private is implicit in C# and the keyword only adds noise. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0022` ## What it checks Private is implicit in C#, so explicit modifiers add noise. ## Diagnostic message `Avoid explicit 'private' modifier` ## How to resolve Remove explicit `private` modifiers, except for property setters where it remains allowed. # CRARCH0023 Use typed logger category > CRARCH0023 requires typed logger categories so that log events align with the type that produced them. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0023` ## What it checks Typed logger categories align log events with the producing type. ## Diagnostic message `Inject ILogger<{0}> instead of '{1}'` ## How to resolve Inject `ILogger` instead of non-generic logger forms. # CRARCH0024 LoggerMessage container conventions > CRARCH0024 requires LoggerMessage methods to live in convention-based containers, so logging stays consistent. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0024` ## What it checks LoggerMessage methods must live in convention-based containers for consistency. ## Diagnostic message `Type '{0}' with [LoggerMessage] methods must be an internal static partial *LogMessages class` ## How to resolve Move `[LoggerMessage]` methods into `internal static partial` classes with a `LogMessages` suffix. # CRARCH0025 Use Cratis Fundamentals traces > CRARCH0025 asks tracing to go through Cratis Fundamentals abstractions rather than raw tracing APIs, for consistency. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0025` ## What it checks Tracing should flow through Cratis Fundamentals abstractions for consistency. ## Diagnostic message `Avoid direct ActivitySource.StartActivity usage` ## How to resolve Use `IActivitySource`, `IActivityScope`, and `[Span]` generated methods instead of direct `ActivitySource.StartActivity` calls. # CRARCH0026 Use Cratis Fundamentals metrics > CRARCH0026 asks metrics to use Cratis Fundamentals abstractions instead of creating raw instruments directly. * **Category:** Architecture * **Default severity:** Warning * **Diagnostic ID:** `CRARCH0026` ## What it checks Metrics should use Cratis Fundamentals abstractions instead of raw instrument creation. ## Diagnostic message `Avoid direct Meter.{0} usage` ## How to resolve Use `IMeter`, `IMeterScope`, and generated metric methods instead of creating meter instruments directly. # Auth and compliance end to end > How authentication, tenant resolution, Arc authorization, Chronicle compliance subjects, PII encryption, read models, and erasure fit together across the Cratis stack. Security in a Cratis application is not one feature in one package. A request crosses the edge, becomes an Arc command or query, writes events through Chronicle, builds read models, and comes back to a React screen. Authentication, authorization, tenant isolation, and compliance each belong to a different part of that journey, and the stack is designed so each concern has one clear owner. This page follows one request through those owners and points out where your own decisions still matter. The short version: **AuthProxy authenticates and resolves tenant context, Arc authorizes the operation, Chronicle stores events in the right namespace and encrypts PII by compliance subject, and Arc releases PII before query results reach the client.** ``` Tenant-ID + identity events + subject Browser or API client AuthProxy authenticate + resolve tenant Arc /.cratis/me identity enrichment Arc commands and queries authorize operation Chronicle tenant namespace Event log PII encrypted by subject Read models encrypted at rest Arc read-model release decrypts for response Components / React typed result ``` ## The edge owns authentication Put [AuthProxy](/authproxy/) in front of your frontend and backend services when you want one place to own the browser and API edge. It handles OpenID Connect, OAuth providers, JWT bearer authentication, provider selection, invite onboarding, tenant resolution, and routing. AuthProxy then forwards requests with the resolved tenant and identity context. In an Arc application, the identity-enrichment endpoint is `/.cratis/me`: AuthProxy calls it, Arc composes the domain-specific identity payload, and the frontend can consume the resulting identity state instead of making every service rediscover the user. Authentication is not authorization Authentication answers “who is calling?” Tenant resolution answers “which tenant are they acting in?” Authorization answers “can they run this operation?” Compliance answers “whose personal data is this, and how is it protected?” ## Arc owns operation-level access Arc applies authorization where the behavior is declared: commands and queries. Use ASP.NET Core authorization attributes, Arc’s `[Roles]` convenience attribute, and policy-based authorization on the command record or read-model query method that represents the operation. That matters because authorization happens before the behavior runs. If a command is not authorized, its `Handle()` method is not executed. If a query is not authorized, the read is not performed. The generated frontend proxy receives authorization state as part of the command or query result, so screens can react without hand-written protocol glue. Use edge authentication for entry, then command/query authorization for business operations. Do not rely on the UI hiding a button as the only access-control layer. ## Tenant context follows the request into Chronicle In a multi-tenant Cratis application, the tenant resolved at the edge becomes application context in Arc. When Arc is integrated with Chronicle, that tenant context is used as the Chronicle namespace. Events, projections, reducers, and read models for one tenant go to that tenant’s namespace without every command manually choosing one. This is the operational boundary: | Concern | Owner | Result | | --- | --- | --- | | Resolve the tenant | AuthProxy or Arc tenancy resolvers | One tenant id for the request | | Carry tenant through command/query handling | Arc | Commands, queries, filters, identity, and generated endpoints see the same tenant context | | Isolate event-sourced state | Arc + Chronicle integration | Chronicle uses the tenant as the event-store namespace | | Isolate MongoDB storage | Arc MongoDB integration | The injected `IMongoDatabase` and `IMongoCollection` resolve a tenant-specific database (`{Database}+{TenantId}` for non-default tenants) | | Isolate EF Core storage | You | Arc does not append the tenant to the connection string; you choose and implement relational isolation | | Check tenant membership | You | Resolution selects a tenant; your services and policies verify that this caller may use it | A correct resolver is not membership authorization Keep three decisions apart: selection resolves the tenant id, membership authorization checks whether this caller may use it, and storage isolation decides where reads and writes go. None of them replaces the others, and a resolver that produces the right tenant id does not prove the authenticated user belongs to that tenant. Enforce membership in your application services and policies. Isolation is also only as good as the tenant id the resolver produces. A request that arrives without a tenant does not fail: Arc maps a missing or default tenant to Chronicle’s **default** namespace. In a multi-tenant application, decide whether any caller may reach default-namespace data, and reject requests without a valid tenant when they may not. Trust tenant headers only when they come from AuthProxy or another component you control. [Tenancy with Chronicle](/arc/backend/csharp/chronicle/tenancy/) describes the mapping, and [database isolation](/arc/backend/csharp/tenancy/database-resolvers/) covers MongoDB and EF Core. ## Compliance subject decides the PII key Chronicle compliance uses a **subject** to identify whose protected data an event contains. That subject is the lookup key for PII encryption material. It is often a person or customer id; it is not always the authenticated user, and it is not always the aggregate id. For example, an order event may be appended to an `OrderId` event source while the protected email address in the event belongs to a `CustomerId`. In that case the command should provide the customer as the Chronicle `Subject`. If no explicit subject is provided, Chronicle falls back to the event source id. Arc’s Chronicle integration can resolve the subject from command return values, command properties, `ICanProvideSubject`, or a `[Subject]`-marked property. The rule of thumb is simple: **set the subject to the identity that owns the PII, not merely the thing being changed.** ## PII is protected at write and released at read Chronicle detects `[PII]` on event properties and on `ConceptAs` value types. At append time, those values are encrypted under the subject’s key before they are stored in the event log. Read models keep that protection: | Read-model style | How PII is tracked | | --- | --- | | Projection | Chronicle can infer PII lineage from mapped event properties. | | Reducer | The read model must mark PII properties explicitly, because reducer logic is arbitrary C#. | | Arc query response | Arc releases the read model before serving it, so clients receive decrypted values when the subject key exists. | Managed read-model documents also carry Chronicle’s reserved `__subject` field (and a `__subjects` map when properties belong to different subjects) so the correct key can be used during release. Do not declare your own properties named `_subject`, `__subject`, or `__subjects`; Chronicle reserves all three. ## Commands leave a permanent record With Arc and Chronicle together, a command’s name and property values are written into the causation chain of the events it appends. That chain lives in the event log and is as permanent as the events, and it is **not** covered by event encryption. Mark top-level command properties `[PII]` or `[NotAudited]` to keep them out of it. The exclusion is not recursive: a marked member inside a nested object or collection is still written. Exclude the containing property, its type, or the whole command when nested data is sensitive. See [causation](/arc/backend/csharp/chronicle/commands/causation/). ## Erasure and its limits For the concept and a comparison of erasure approaches, see [Event sourcing and GDPR](/concepts/event-sourcing-gdpr/). The event log can stay structurally immutable while protected values become unreadable. For GDPR-style erasure, Chronicle deletes the subject’s PII encryption key. After that, PII encrypted with that key cannot be decrypted by the running system. Erasure also records a fence that prevents Chronicle from silently creating a replacement key for that subject in the affected namespace. It does not automatically remove stored ciphertext. Do not assume a replay or rebuild will clean it up: processing a protected value for an erased subject can fail with `EncryptionKeyErased`. Follow the [subject-erasure procedure](/chronicle/compliance/erasing-a-subject/) for the affected namespaces and read models. Authorizing a new key is a separate decision, not a step needed to finish erasure. If an entire event payload must be removed, review [event redaction](/chronicle/events/redaction/) as a separate operation. Key deletion only reaches what Chronicle’s key store protects. Plan for the rest: * **Backups.** A backup taken before the erasure still holds the key. Restoring it brings the key back, so a restore needs its own procedure to re-apply erasures made since the backup. * **Copies outside Chronicle.** Decrypted values in exports, warehouses, search indexes, logs, caches, and anything a reactor sent to another system are outside the erasure. * **Unmarked data.** Values that were never marked `[PII]`, including command values recorded in causation, were never encrypted and are not affected. [Erasing a subject](/chronicle/compliance/erasing-a-subject/) and [key lifecycle](/chronicle/compliance/key-lifecycle/) cover the full procedure. ## What to decide up front | Decision | Ask this | Start here | | --- | --- | --- | | Identity provider | Where do browser users and API clients authenticate? | [AuthProxy authentication](/authproxy/configuration/authentication/) | | Tenant resolution | Which host, claim, route, or configured value selects the tenant? | [AuthProxy tenancy](/authproxy/configuration/tenancy/) | | Operation access | Which roles or policies can run each command or query? | [Arc authorization](/arc/backend/csharp/core/authorization/) | | Compliance subject | Whose personal data does this event contain? | [Arc Chronicle subject](/arc/backend/csharp/chronicle/compliance/subject/) | | PII modeling | Which event properties or concepts are personal data? | [Chronicle compliance](/chronicle/compliance/) | | Command audit | Which command values must stay out of the permanent causation record? | [Arc causation](/arc/backend/csharp/chronicle/commands/causation/) | | Erasure | What must be key-deleted, re-projected, redacted, or handled outside Chronicle? | [Erasing a subject](/chronicle/compliance/erasing-a-subject/) | ## Go deeper [AuthProxy ](/authproxy/)Authenticate users, resolve tenants, enrich identity, and route requests at the edge. [Arc identity ](/arc/backend/csharp/identity/)Expose /.cratis/me and compose the identity details AuthProxy and the frontend consume. [Arc authorization ](/arc/backend/csharp/core/authorization/)Authorize commands and queries with attributes, roles, and policies before behavior runs. [Chronicle subject ](/arc/backend/csharp/chronicle/compliance/subject/)Set the compliance subject so PII is encrypted under the correct identity. [Chronicle compliance ](/chronicle/compliance/)Understand PII annotations, per-subject encryption keys, read models, and erasure. [Production readiness ](/production-readiness/)Review the operational checklist for TLS, secrets, storage, observability, and deployment. # AuthProxy > AuthProxy is a lightweight .NET gateway that sits in front of your services and handles the cross-cutting edges — authentication, multi-tenancy, identity enrichment, and invite-based onboarding — so your application code stays focused on the domain. The Cratis Stack ## A gateway for the edges of your app Every application eventually grows the same crop of edge concerns: who is this user, which tenant are they in, where does this request route, and how do you onboard someone who has been invited but doesn’t have an account yet. **AuthProxy** is a small .NET gateway that owns those edges — so each of your services can assume the request is already authenticated, already scoped to a tenant, and already enriched with identity. ## The friction it removes Without a gateway, every service re-implements the same boilerplate: an OpenID Connect handshake, tenant resolution from the host or a claim, a call to fetch the user’s profile, the invite-acceptance flow. It’s repetitive, it drifts between services, and it’s exactly the kind of code you don’t want copy-pasted across a fleet. AuthProxy is a [reverse proxy](https://microsoft.github.io/reverse-proxy/) (built on YARP) that you put in front of your backend and frontend services. It authenticates the request, resolves the tenant, enriches the identity, and *then* forwards the request to your service — with the tenant and identity attached as headers. Your services trust the proxy and read those headers. ``` /api/** + x-cratis-tenant-id + identity /** AuthProxy Authenticate (OIDC / JWT Bearer) Resolve tenant Enrich identity (/.cratis/me) Browser / client Backend service Frontend SPA ``` Standalone — not Cratis-only AuthProxy is a plain ASP.NET Core service. It pairs naturally with [Arc](/arc/)’s identity model (it calls a `/.cratis/me` endpoint to enrich identity, which Arc exposes), but it sits in front of *any* backend and frontend you point it at. ## What it handles [ Authentication](/authproxy/configuration/authentication/) [OpenID Connect and OAuth 2.0 (single or multi-provider), plus JWT Bearer. Unauthenticated requests are challenged or sent to a provider-selection page.](/authproxy/configuration/authentication/) [ Authorization](/authproxy/configuration/authorization/) [Require a claim — a role, a group, a GitHub organization or team — before any request is forwarded. Authenticating everyone is not the same as letting everyone in.](/authproxy/configuration/authorization/) [ Identity enrichment](/authproxy/configuration/services/#identity-enrichment) [Calls a `/.cratis/me` endpoint on your service to decide admission and to enrich the readable identity cookie with your service’s details. The forwarded identity header is built from the authenticated session only.](/authproxy/configuration/services/#identity-enrichment) [ Multi-tenancy](/authproxy/configuration/tenancy/) [Resolve the current tenant per request — from the host, a subdomain, a claim, the route, or a fixed value — with optional remote verification.](/authproxy/configuration/tenancy/) [ Invites & lobby](/authproxy/configuration/lobby/) [Invite-based onboarding with signed JWT tokens, plus an optional lobby service for users not yet assigned to a tenant.](/authproxy/configuration/lobby/) ## How the docs are organized There’s no code to write — AuthProxy is configured entirely through the `Cratis:AuthProxy` section of `appsettings.json` and runs as a container in front of your services. Each page under [Configuration](/authproxy/configuration/) takes one edge concern and walks through how AuthProxy handles it and how you configure it. ## Where to go next Start by putting the proxy in front of a service — the configuration reference covers the full `Cratis:AuthProxy` shape. [ Configuration reference](/authproxy/configuration/) [The complete `Cratis:AuthProxy` settings shape, service routing, and every topic below it.](/authproxy/configuration/) [ Aspire hosting](/authproxy/aspire/) [Wire AuthProxy into a .NET Aspire AppHost alongside your other services.](/authproxy/aspire/) [ Deploying on Azure](/authproxy/azure/) [Run AuthProxy on App Service or Container Apps with Microsoft Entra ID sign-in, and keep your backends reachable only through the proxy.](/authproxy/azure/) [ Identity & access in Arc](/arc/backend/csharp/core/authorization/) [How Arc models identity and authorization on commands and queries — the `/.cratis/me` endpoint AuthProxy enriches from.](/arc/backend/csharp/core/authorization/) # Aspire Hosting Integration > Host AuthProxy in a .NET Aspire app model as a container or project resource, and configure services, admission, authentication and tenancy from code. ## Canonical provider identity Use the canonical provider helpers when every downstream identity path must use one explicitly selected provider subject: ```csharp authProxy.WithCanonicalOidcProvider( "Microsoft Entra", OidcProviderType.Microsoft, "https://login.microsoftonline.com//v2.0", clientId, clientSecret, "entra-workforce", "oid"); ``` For OAuth providers, `WithCanonicalOAuthProvider` additionally requires the explicit issuer assigned to the authenticated user-information flow. The `WithOidcProvider` and `WithOAuthProvider` helpers retain legacy identity behavior. The `Cratis.AuthProxy.Aspire` NuGet package adds first-class .NET Aspire support for AuthProxy. Instead of configuring environment variables by hand, you wire up authentication, tenancy, and service routing with a concise fluent API in your `AppHost`. ## Installation ```bash dotnet add package Cratis.AuthProxy.Aspire ``` ## Adding AuthProxy as a container resource This is the typical path for external consumers who run AuthProxy from Docker Hub: ```csharp var authproxy = builder.AddAuthProxy("authproxy", tag: "latest") .WithHttpEndpoint(port: 8080) .WithBackend("main", apiResource) .WithFrontend("main", webResource) .WithOidcProvider( "Microsoft", OidcProviderType.Microsoft, authority: "https://login.microsoftonline.com//v2.0", clientId: "", clientSecret: "") .WithHostTenantResolution(); ``` `AddAuthProxy` creates an `AuthProxyResource` backed by the `cratis/authproxy` Docker Hub image. Pin `tag` to a specific release in production environments — the default `"latest"` is convenient for local development. ## Adding AuthProxy as a project resource When working inside the AuthProxy repository itself (or in a monorepo that includes AuthProxy source), use `AddProject` with the same extension methods: ```csharp var authproxy = builder.AddProject("authproxy") .WithBackend("main", apiResource) .WithFrontend("main", webResource); ``` All `With*` methods work on any `IResourceBuilder where T : IResourceWithEnvironment`, so you can mix container and project resources freely. *** ## Services Use `WithBackend` and `WithFrontend` to register the resources that AuthProxy should proxy: ```csharp authproxy .WithBackend("main", apiResource) .WithFrontend("main", webResource); ``` Both methods accept an optional `endpointName` parameter (defaults to `"http"`) that selects which endpoint from the target resource to forward to. ### Identity details resolution For each service with a backend, AuthProxy calls `GET {baseUrl}/.cratis/me` after authentication to enrich the identity cookie. This behaviour is on by default. To disable it for a specific service, pass `resolveIdentityDetails: false` to `WithBackend`: ```csharp authproxy .WithBackend("reporting", reportingApi, resolveIdentityDetails: false) .WithFrontend("reporting", reportingWeb); ``` See [Services](/authproxy/configuration/services/) for the underlying configuration model. ### Identity verification mode A service that takes part in identity resolution is verified by default: unless the app host says otherwise, AuthProxy treats the answer on `/.cratis/me` as an authorization decision, and anything other than an explicit positive denies the request. The Aspire package writes the setting only when you call `WithIdentityVerification`, so an app host that never calls it gets that default. Opt a service whose endpoint only enriches, or does not exist, out explicitly: ```csharp authproxy .WithBackend("reporting", reportingApi) .WithIdentityVerification("reporting", IdentityVerificationMode.BestEffort); ``` ### Identity verification denials Because a service’s identity endpoint is an authorization authority unless you opt out, you can make any denial end the caller’s local AuthProxy session: ```csharp authproxy .WithIdentityVerification("main", IdentityVerificationMode.Required) .WithSessionTerminationOnIdentityDenial(); ``` `WithSessionTerminationOnIdentityDenial` is global and composes deterministically across services: calling it more than once still writes the same enabled session setting. A denial clears AuthProxy’s local session before the existing `403` response; it does not initiate logout at the external identity provider. Omit the call to preserve the default behavior, where the authenticated session remains active after a denial. See [Identity verification](/authproxy/configuration/services/#identity-enrichment) for the denial matrix and the direct configuration equivalent. ### Anonymous paths Declare the request paths on a service that should be served without a session — a magic-link landing page, a signed-token report, a public webhook receiver. Call `WithAnonymousPaths` once per service; each call accumulates entries: ```csharp authproxy.WithAnonymousPaths("main", "/welcome", "/api/webhooks/payments"); ``` Each entry is a rooted path prefix, matched case-insensitively on segment boundaries — `/welcome` covers `/welcome` and `/welcome/anything`, but not `/welcomex`. AuthProxy still strips inbound identity headers on these paths and the application remains responsible for authorizing them. See [Anonymous paths](/authproxy/configuration/services/#anonymous-paths) for the full matching rules and what the flag does and does not change. ### Activity timeout A proxied request that sits idle — no bytes in either direction — for five minutes is cancelled, which also ends a quiet WebSocket or Server-Sent Events stream. Raise the limit for every service, or for one: ```csharp authproxy.WithActivityTimeout(TimeSpan.FromMinutes(10)); authproxy.WithServiceActivityTimeout("reporting", TimeSpan.FromMinutes(2)); ``` The service value wins over the global one. See [Timeouts and streaming](/authproxy/configuration/services/#timeouts-and-streaming) for what a stream needs beyond the timeout. ### Trusted proxies Declare the peers directly in front of AuthProxy, so their `X-Forwarded-For` and `X-Forwarded-Proto` are believed and everybody else’s are not: ```csharp authproxy .WithTrustedProxies("10.0.0.0/8", "203.0.113.7") .WithForwardLimit(2); ``` `WithTrustedProxies` takes IP addresses and CIDR ranges and accumulates across calls; an entry that is neither is refused when the app host builds. `WithForwardLimit` is the number of hops a request legitimately passes through, and it decides which address ends up reported as the client — see [Trusted Proxies](/authproxy/configuration/trusted-proxies/) for how to choose it and what an untrusted caller can do while it is unset. *** ## Admission Close the interactive contract, so AuthProxy answers nothing at all until a caller presents a capability the deployment’s own verifier admits: ```csharp authproxy.WithCapabilityOnlyAdmission("https://members.example.com/admit"); ``` That is the whole minimum — the verifier URL has no default, because the verifier is your service and inventing an address for it would mean a misconfigured deployment silently calling something else. Everything else does: ```csharp authproxy.WithCapabilityOnlyAdmission( verifierUrl: "https://members.example.com/admit", path: "/enter", // default "/.cratis/admission" maximumLength: 512, // default 4096 bytes entryLifetime: TimeSpan.FromMinutes(30)); // default 20 minutes ``` | Parameter | Required | Description | | --- | --- | --- | | `verifierUrl` | ✓ | Absolute `http`/`https` URL that decides whether a presented capability admits. | | `path` | – | The one path a capability may be presented on. | | `maximumLength` | – | The largest capability, in bytes, AuthProxy will read. | | `entryLifetime` | – | How long an admitted browser stays admitted. | Do not shorten `entryLifetime` below fifteen minutes: ASP.NET Core allows that long at the identity provider, so a shorter entry expires while the handshake is still live and the caller comes back to a `404` with nothing anywhere to diagnose it from. It cannot be combined with `WithInvite` — AuthProxy refuses the combination at startup rather than silently ordering two capability mechanisms. Without this call nothing changes: the default mode is `Public`, which is how every release before it behaved. See [Admission](/authproxy/configuration/admission/) for the verifier request/response contract, what an operator should expect to observe, and the `/.cratis/token` caveat for machine clients. *** ## Authentication ### OIDC providers ```csharp authproxy.WithOidcProvider( name: "Contoso AD", type: OidcProviderType.Microsoft, authority: "https://login.microsoftonline.com//v2.0", clientId: "", clientSecret: "", scopes: ["api://my-api/.default"]); ``` Call `WithOidcProvider` once per provider. Multiple calls produce a provider-selection page. The `OidcProviderType` enum contains well-known provider brands: | Value | Description | | --- | --- | | `Custom` | Generic / unknown provider. | | `Microsoft` | Microsoft Identity Platform (Azure AD / Entra ID). | | `Google` | Google Identity. | | `GitHub` | GitHub OAuth / OIDC. | | `Apple` | Apple Sign-In. | ### OAuth 2.0 (non-OIDC) providers For providers that do not expose an OIDC discovery document, use `WithOAuthProvider`: ```csharp authproxy.WithOAuthProvider( name: "GitHub", type: OidcProviderType.GitHub, authorizationEndpoint: "https://github.com/login/oauth/authorize", tokenEndpoint: "https://github.com/login/oauth/access_token", userInformationEndpoint: "https://api.github.com/user", clientId: "", clientSecret: "", scopes: ["user:email"], claimMappings: new Dictionary { ["sub"] = "id", ["name"] = "login", ["email"] = "email" }, authorizationParameters: new Dictionary { ["prompt"] = "select_account" }); ``` `authorizationParameters` adds provider-specific, static values to every authorization request without changing the endpoint URL. AuthProxy rejects parameters owned by the OAuth handler, including `state`, `redirect_uri`, `client_id`, `scope`, `response_type`, and PKCE parameters, so configured values cannot replace correlation or callback data. For invitation acceptance, also configure the OAuth provider’s `VerifiedEmailEndpoint` as `https://api.github.com/user/emails` through AuthProxy configuration. The `user:email` scope shown above lets AuthProxy establish exactly one primary verified address instead of trusting the nullable address on `/user`. See [Authentication](/authproxy/configuration/authentication/) for the full configuration reference. For an invite system that creates or links accounts, enable signed two-stage attestations after calling `WithInvite`: ```csharp authproxy.WithSignedInvitationAttestations( stageUrl: "https://lobby.example.com/_invite/stage", issuer: "https://auth.example.com", audience: "ada-lobby", keyId: "invite-2026-08", privateKeyPem: invitationSigningKey); ``` Load `invitationSigningKey` from a secret provider and configure the invitation authority with only the matching public key. Signed attestations also require recipient binding — call [`WithInviteEmailBinding`](#binding-an-invitation-to-the-invited-email) with a non-empty claim and pass a `tenantClaim` to `WithInvite`, or AuthProxy fails options validation at startup. See [Invitation to Organization](/authproxy/configuration/lobby/invitation-to-organization/) for the claims, two calls, verification rules, and rotation sequence. *** ## Tenant resolution Add one or more resolution strategies. They run in order until a tenant is matched: | Method | Strategy | | --- | --- | | `WithHostTenantResolution()` | Matches the request host against configured tenant domains. | | `WithSubHostTenantResolution()` | Derives the tenant from the first subdomain (e.g. `acme.example.com` → `acme`). | | `WithClaimTenantResolution(claimType?)` | Reads a claim from the authenticated user. | | `WithRouteTenantResolution(pattern)` | Extracts a source identifier from the request path by regex. | | `WithSpecifiedTenantResolution(tenantId)` | Pins all requests to one fixed tenant (single-tenant deployments). | | `WithDefaultTenantResolution(tenantId)` | Fallback when no other strategy resolves a tenant. | | `WithSelectionTenantResolution()` | Reads the tenant from the cookie set by the tenant-selection page. | ```csharp authproxy .WithSubHostTenantResolution() .WithDefaultTenantResolution("lobby"); ``` See [Tenancy](/authproxy/configuration/tenancy/) for detailed strategy documentation. ### Tenant verification After resolution, AuthProxy can confirm the tenant exists by calling your back-end. You can pass a raw URL template or reference an Aspire service resource directly: ```csharp // Raw URL template authproxy.WithTenantVerification("https://platform.example.com/api/tenants/{tenantId}"); // Aspire resource reference — endpoint is resolved automatically authproxy.WithTenantVerification(platformApi, "/api/tenants/{tenantId}"); ``` AuthProxy issues a `GET` to the resolved URL. A `200` response lets the request proceed; `404` or any error serves the `tenant-not-found.html` page. *** ## Tenant selection When users can be members of more than one tenant, the `Selection` strategy presents a tenant-selection page after login. You can pass a raw URL or reference an Aspire service resource: ```csharp // Raw URL authproxy.WithSelectionTenantResolution( tenantsEndpoint: "https://platform.example.com/api/tenants/selectable"); // Aspire resource reference — endpoint is resolved automatically authproxy.WithSelectionTenantResolution(platformApi, "/api/tenants/selectable"); ``` AuthProxy calls the endpoint after login and, if more than one tenant is returned, serves the built-in `select-tenant.html` page. If only one tenant is returned the selection page is skipped and the user is redirected immediately. The endpoint must return a JSON array of `{ "id": "...", "name": "..." }` objects. See [Tenant Selection Page](/authproxy/configuration/tenant-selection/) for details on building a custom selection page and the full flow. *** ## Invites, registration and lobby ### Core invite configuration Configure the invite system with the RSA public key and exchange endpoint. You can pass a raw URL or reference an Aspire service resource for the exchange endpoint: ```csharp // Raw URL authproxy.WithInvite( publicKeyPem: File.ReadAllText("invite-public-key.pem"), exchangeUrl: "https://studio.example.com/internal/invites/exchange", issuer: "https://studio.example.com", audience: "authproxy", tenantClaim: "tenant_id", subjectAlreadyExistsUrl: "https://app.example.com/errors/account-already-exists"); // Aspire resource reference — exchange endpoint URL is resolved automatically authproxy.WithInvite( publicKeyPem: File.ReadAllText("invite-public-key.pem"), exchangeServiceResource: studioApi, exchangeRoute: "/internal/invites/exchange", issuer: "https://studio.example.com", tenantClaim: "tenant_id"); ``` | Parameter | Required | Description | | --- | --- | --- | | `publicKeyPem` | ✓ | PEM-encoded RSA public key to verify invite token signatures. | | `exchangeUrl` | ✓ | Endpoint called after login to exchange the invite token. | | `issuer` | – | Expected `iss` claim. Omit to skip issuer validation. | | `audience` | – | Expected `aud` claim. Omit to skip audience validation. | | `tenantClaim` | – | Claim that carries the tenant ID used in the matching-tenant comparison. | | `subjectAlreadyExistsUrl` | – | Redirect URL when the exchange endpoint returns HTTP 409. Omit to serve the built-in page. | `MatchingTenantInvitationDestination` defaults to `InvitationCompletionDestination.ReturnUrl`, preserving the existing direct-to-service flow for matching-tenant invitations. For platform-level invitations that must continue through Lobby setup even when the tenant claim matches the resolved tenant, compose this after either `WithInvite` overload: ```csharp authproxy.WithMatchingTenantInvitationDestination(InvitationCompletionDestination.Lobby); ``` The method writes `Cratis__AuthProxy__Invite__MatchingTenantInvitationDestination`. It changes only the redirect after a successful completion; staging, completion, tenant matching, recipient binding, attestations, transactions, cookies, and sessions are unchanged. ### Binding an invitation to the invited email By default an invite is a bearer token: any subject who signs in holding it can redeem it. To bind it to the address it was issued to, name the claim in the invite token that carries that address: ```csharp authproxy.WithInviteEmailBinding("invited_email"); ``` Compose this after either `WithInvite` overload. AuthProxy then compares that claim against the email evidence the identity provider supplied for the signed-in session, before the second-stage exchange runs. When the provider offers no usable address, the invite is rejected with `invitation-email-unavailable.html`; when the address differs from the invited one — or the provider explicitly reports `email_verified=false` — with `invitation-email-mismatch.html`. Omitting the call, or passing an empty claim, writes nothing and retains the released default of no recipient binding. ### Claim forwarding To propagate invite-token claims into the principal sent to `/.cratis/me` endpoints, call `WithInviteClaimForwarding` once per claim: ```csharp authproxy .WithInviteClaimForwarding("organization_id", toClaimType: "organization") .WithInviteClaimForwarding("invited_by"); ``` When `toClaimType` is omitted the original claim type is preserved. ### Lobby The lobby is the service users are redirected to when no tenant can be resolved — typically an onboarding application. At minimum, configure the lobby frontend: ```csharp authproxy .WithLobbyFrontend(lobbyResource) .WithLobbyBackend(lobbyApiResource); // optional ``` `WithLobbyFrontend` and `WithLobbyBackend` both accept an optional `endpointName` parameter (defaults to `"http"`). ### Registration To send users through the AuthProxy registration bootstrap flow, configure a lobby registration URL: ```csharp authproxy.WithLobbyRegistration(lobbyResource, "/register"); // or use a raw URL authproxy.WithLobbyRegistration("https://lobby.example.com/register"); ``` This sets `Cratis:AuthProxy:Invite:Lobby:Registration:BaseUrl`. Users who visit `/register` authenticate through AuthProxy and are then redirected to that registration URL. See [Lobby](/authproxy/configuration/lobby/) for the onboarding flow walkthroughs. ## One proxy, several hosts Give each service its own hosts or path prefix, and share one sign-in across them: ```csharp authproxy .WithHosts("console", "console.example.com") .WithHosts("operations", "operations.example.com") .WithPathPrefix("reporting", "/reporting", stripPathPrefix: true) .WithSessionCookieDomain(".example.com") .WithSignInCallbackHost("auth.example.com"); ``` `WithHosts` appends when called more than once for a service. `WithSessionCookieDomain` shares the session cookie with every host under the domain, `WithSessionCookieName` renames it, and `WithSignInCallbackHost` runs every sign-in handshake on one host, which requires the cookie domain. None of them changes anything until you call it. See [One proxy, several hosts](/authproxy/configuration/one-proxy-several-hosts/) for the whole setup and what AuthProxy refuses to start with. # Deploying on Azure > Deploy AuthProxy on Azure with Microsoft Entra ID sign-in and keep your backends reachable only through the proxy, with the setup shared by both platforms. This guide takes AuthProxy from nothing to a working deployment on Azure, signing users in with Microsoft Entra ID, and keeping your backends reachable **only** through the proxy. The platform-specific steps are in [App Service](/authproxy/azure/app-service/) and [Container Apps](/authproxy/azure/container-apps/); everything that is the same on both is here. ``` HTTPS sign-in identity headers must not reach Browser AuthProxy (public) Microsoft Entra ID Backend (private) Frontend (private) Anyone else ``` *** ## Backends trust AuthProxy, so only AuthProxy may reach them This is the one decision in the whole deployment that cannot be fixed afterwards. AuthProxy strips the identity headers a caller sends, authenticates the caller, and writes `x-ms-client-principal`, `x-ms-client-principal-id` and `x-ms-client-principal-name` itself. Your backend believes them. They are **not signed**: a backend cannot tell a request that went through AuthProxy from one that did not, except by the network it arrived on. See [Forwarded identity headers](/authproxy/configuration/authentication/#forwarded-identity-headers). So a backend that anyone other than AuthProxy can open a connection to is a backend anyone can impersonate any user on, by sending the header. The platform features that prevent this differ, and getting them wrong is easy because the failure is silent: the application works perfectly, for everybody, including the person forging the header. Each platform page ends with an isolation recipe. The [direct-access test](#test-that-a-backend-cannot-be-reached-directly) below checks the routes you probe; it does not prove that no other route to the backend exists. *** ## Register the application in Microsoft Entra ID 1. In the Microsoft Entra admin center, **App registrations** > **New registration**. Choose **Accounts in this organizational directory only** (single tenant) unless you really do accept other organizations. 2. Under **Authentication**, add a **Web** platform with two redirect URIs, using the public host name users type (`auth.example.com` below): | Redirect URI | Why | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `https://auth.example.com/signin-microsoft` | The sign-in callback. The last segment is `signin-` plus the provider `Name` lowercased with spaces replaced by hyphens, so a provider named `Microsoft` gives `signin-microsoft`. | | `https://auth.example.com/.cratis/logout/callback` | The post-logout callback. Microsoft requires `post_logout_redirect_uri` to match a registered redirect URI. See [Logout](/authproxy/configuration/logout/#full-chain-logout). | Entra accepts `http` only for `localhost`, so register the public `https` address, not the platform’s internal one. 3. Under **Certificates & secrets**, create a **client secret**. See [the credential](#the-credential) below before you pick a lifetime. 4. Copy the **Application (client) ID** and the **Directory (tenant) ID**. ### The credential Microsoft recommends certificate or federated (managed identity) credentials for confidential clients, and advises against secrets in production. AuthProxy’s OIDC provider takes **a client secret only** (`ClientSecret`); certificate and federated credentials are not supported yet and are tracked in [Cratis/AuthProxy#149](https://github.com/Cratis/AuthProxy/issues/149). Until then: * Keep the secret out of source control and out of plain app settings. Hold it in Key Vault and reference it from the platform (the platform pages show how). * Choose a short lifetime and put the expiry in a calendar or an alert. When it expires, **every sign-in fails** until the setting is replaced. * Rotate with overlap: Entra lets two secrets be valid at once, so add the new one, update the setting, restart, then delete the old one. ### Who may sign in A single-tenant authority already refuses accounts from other directories, but every account in **your** directory can sign in. Narrow it in this order, using as many as you need: * On the **enterprise application**, turn on **Assignment required**. Only users you assign can then sign in. Grant tenant-wide admin consent to the application first, because user consent is not allowed once assignment is required. Assigning groups rather than individual users needs Microsoft Entra ID P1 or P2. * Define **app roles** on the app registration, assign them to users or groups, and require one in AuthProxy with [`Authorization`](/authproxy/configuration/authorization/). Entra puts assigned roles in the `roles` claim of the ID token. * Do both for anything sensitive. Assignment keeps strangers out; the AuthProxy requirement keeps a mistakenly-assigned or newly-added user without the role out of services that need more. ### The AuthProxy provider ```json { "Cratis": { "AuthProxy": { "Authentication": { "OidcProviders": [ { "Name": "Microsoft", "Type": "Microsoft", "Authority": "https://login.microsoftonline.com//v2.0", "ClientId": "", "ClientSecret": "", "CanonicalIdentity": { "ProviderKey": "entra-workforce", "SubjectClaimType": "oid" } } ] }, "Authorization": { "RequiredClaims": [ { "Claim": "roles", "AnyOf": [ "AuthProxy.User" ] } ] } } } } ``` * **Use the tenant-specific authority** shown, not `common` or `organizations`. A multi-tenant authority accepts any Entra directory. * **`CanonicalIdentity` with `SubjectClaimType: oid`** makes the stable object ID the user’s subject, instead of a value Entra can change or reassign. See [Canonical federated identity](/authproxy/configuration/authentication/#canonical-federated-identity). It also keeps Entra’s claim names as they are in the token, which is why the requirement above says `roles`. **Without** `CanonicalIdentity` the .NET handler renames that claim, and the requirement would have to name `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` instead. * Drop the `Authorization` section if you rely on **Assignment required** alone. * Changing `CanonicalIdentity` later signs every existing user out, so decide before go-live. On both platforms these are set as environment variables; `__` replaces `:` and array positions are indexes: ```bash Cratis__AuthProxy__Authentication__OidcProviders__0__Name=Microsoft Cratis__AuthProxy__Authentication__OidcProviders__0__Type=Microsoft Cratis__AuthProxy__Authentication__OidcProviders__0__Authority=https://login.microsoftonline.com//v2.0 Cratis__AuthProxy__Authentication__OidcProviders__0__ClientId= Cratis__AuthProxy__Authentication__OidcProviders__0__ClientSecret= Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__ProviderKey=entra-workforce Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__SubjectClaimType=oid Cratis__AuthProxy__Authorization__RequiredClaims__0__Claim=roles Cratis__AuthProxy__Authorization__RequiredClaims__0__AnyOf__0=AuthProxy.User ``` *** ## Point AuthProxy at your services Name each backend and frontend by the address AuthProxy reaches it on from inside your network, following the isolation recipe for [App Service](/authproxy/azure/app-service/#isolating-backends) or [Container Apps](/authproxy/azure/container-apps/#isolating-backends). On App Service this can be the normal `*.azurewebsites.net` name, resolved privately or protected by service-endpoint access restrictions: ```bash Cratis__AuthProxy__Services__portal__Backend__BaseUrl=https:/// Cratis__AuthProxy__Services__portal__Frontend__BaseUrl=https:/// ``` With one service, `/api/**` goes to the backend and everything else to the frontend. With several, a caller names the service on each request. See [Services](/authproxy/configuration/services/). *** ## Settings every Azure deployment needs ### Trusted proxies The platform terminates TLS and forwards the request over plain HTTP, so AuthProxy only knows the request was HTTPS, and who the client is, from `X-Forwarded-Proto` and `X-Forwarded-For`. If those are not believed, **session cookies are issued without `Secure`** and sign-in notifications record the platform’s address as the client. Tell AuthProxy which peers to believe: ```bash Cratis__AuthProxy__Ingress__Mode=Configured Cratis__AuthProxy__Ingress__TrustedProxies__0=... Cratis__AuthProxy__Ingress__ForwardLimit=1 ``` The values differ per platform and are given on each platform’s page. In `Configured` mode, verify and trust every hop before adding one to `ForwardLimit` for a proxy in front of the platform’s own ingress, such as Azure Front Door or Application Gateway. Restrict the origin to the intended upstream proxy so callers cannot bypass it. **For the Container Apps recipe’s `Mode=TrustAny`, keep `ForwardLimit=1` unless access to AuthProxy’s origin is restricted to the intended upstream proxy.** Adding upstream ranges to `TrustedProxies` does not help: `TrustAny` ignores that list. Alternatively, use `Configured` mode with verified trusted hops, including the platform ingress peer, before increasing the limit. In either case, complete the [direct-origin spoofed-header check](/authproxy/azure/container-apps/#adding-an-upstream-proxy) before increasing it. See [Trusted proxies](/authproxy/configuration/trusted-proxies/). Do **not** set `ASPNETCORE_FORWARDEDHEADERS_ENABLED`. ### Data Protection keys AuthProxy encrypts its session cookie and tokens with Data Protection keys. Local disk is not shared between replicas and is not kept across restarts on either platform, so **a deployment with more than one replica will sign users out at random** until the keys are shared. Use Azure Blob Storage, encrypted with a Key Vault key, with a managed identity: ```bash Cratis__AuthProxy__DataProtection__Store=AzureBlob Cratis__AuthProxy__DataProtection__AzureBlob__BlobUri=https://.blob.core.windows.net/dataprotection/authproxy-keys.xml Cratis__AuthProxy__DataProtection__KeyVault__KeyIdentifier=https://.vault.azure.net/keys/authproxy-dataprotection ``` Give AuthProxy’s managed identity the `Storage Blob Data Contributor` role on the container and the `Key Vault Crypto User` role on the key, and create the container and an RSA key first. If the identity is user-assigned, also set `Cratis__AuthProxy__DataProtection__ManagedIdentityClientId`. Every detail, and the Redis alternative, is in [Data protection keys](/authproxy/configuration/data-protection/). ### Timeouts and streaming Frontends that stream (WebSockets, Server-Sent Events) are cut when they go quiet for longer than the shortest idle limit on the path: AuthProxy’s `ActivityTimeout` (five minutes by default) or the platform ingress’s own. Send a heartbeat comfortably inside the shortest of them, and raise AuthProxy’s only if the platform’s is longer. The platform limits are on the platform pages; [Timeouts and streaming](/authproxy/configuration/services/#timeouts-and-streaming) covers AuthProxy’s side. ### Health probes AuthProxy answers nothing about itself on the public port, and an HTTP probe pointed at `/` gets `401`. Use the [management listener](/authproxy/configuration/management-listener/) where the platform can probe a second port, and a declared [anonymous path](/authproxy/configuration/services/#anonymous-paths) where it cannot. The platform pages say which applies. ### Do not turn on the platform’s own sign-in App Service **Authentication** and Container Apps **Authentication** put a second sign-in in front of AuthProxy. AuthProxy does the sign-in; leave the platform feature off for the AuthProxy app. *** ## Test that a backend cannot be reached directly Put this check in your pipeline to detect unexpected responses on the routes you probe. It sends a request that **claims to be a signed-in user** straight to the backend’s own address. It fails on every HTTP response unless you explicitly identify an expected platform refusal, and on indeterminate transport failures. Choose an endpoint that returns `2xx` for **any authenticated principal**, including the exact `forged` fixture below. Arc’s `/.cratis/me` qualifies with its default identity-details provider, or a custom provider that accepts any user. A custom provider that denies the forged user returns an application `403`; an app without that endpoint returns `404`. Neither proves isolation. Before using the probe, send the same three headers and principal below from an allowed location inside the trust boundary and confirm that the chosen endpoint returns `2xx`. Also check the endpoint through AuthProxy with a signed-in user. Save this as `assert-backend-isolated.sh` (requires Bash, curl, base64 and grep): ```bash #!/usr/bin/env bash # Usage: ./assert-backend-isolated.sh URL ['URL|403|platform-specific-body-marker'] [...] # Only DNS/connection refusal, or an explicitly matched platform refusal, passes. set -u if [ "$#" -eq 0 ]; then echo "Usage: $0 URL ['URL|403|platform-specific-body-marker'] [...]" >&2 exit 1 fi body=$(mktemp) || exit 1 trap 'rm -f "$body"' EXIT fail=0 principal=$(printf '{"userId":"forged","userDetails":"forged","claims":[]}' | base64 | tr -d '\n') for target in "$@"; do url=$target expected=000 marker= if [[ "$target" == *'|'* ]]; then url=${target%%|*} refusal=${target#*|} expected=${refusal%%|*} marker=${refusal#*|} if [[ "$refusal" != *'|'* || -z "$marker" || "$marker" == *'|'* ]] || [[ "$expected" != 401 && "$expected" != 403 && "$expected" != 404 ]]; then echo "FAIL invalid target: $target" >&2 fail=1 continue fi fi curl_exit=0 code=$(curl -sS -o "$body" -w '%{http_code}' --max-time 15 \ -H "x-ms-client-principal: ${principal}" \ -H "x-ms-client-principal-id: forged" \ -H "x-ms-client-principal-name: forged" \ -- "$url") || curl_exit=$? if [[ "$code" == 000 && ( "$curl_exit" == 6 || "$curl_exit" == 7 ) ]]; then echo "ok $url -> no connection (curl $curl_exit)" elif [[ "$curl_exit" == 0 && "$code" == "$expected" && "$expected" != 000 ]] && grep -Fq -- "$marker" "$body"; then echo "ok $url -> expected platform refusal $code" else echo "FAIL $url -> HTTP $code, curl $curl_exit; no expected platform refusal" >&2 fail=1 fi done exit "$fail" ``` A plain URL passes only when curl reports `000` with DNS resolution failure (`6`) or connection failure (`7`). `000` alone is not proof that a request never connected: timeouts, TLS failures and other transport errors fail the check. If curl received an HTTP status before a transfer failed, that status is preserved and the check fails. To accept a platform-generated `401`, `403` or `404`, pass a quoted argument such as `'https://portal-api.example.net/.cratis/me|403|'`. Replace the marker with a literal, distinctive substring of the platform’s refusal body, established on your deployment; do not use a generic word such as `Forbidden` or `Not Found`, or an application error message. Both the status and marker must match, and the transfer must complete. There is no universal marker across Azure platforms. If you cannot distinguish the platform refusal from an application response, leave that response failing and investigate; do not whitelist the status alone. A matching response checks only that observed refusal, not every possible route or identity. Run it from two places: 1. **From outside the network**, such as the pipeline runner or a laptop, against the backend’s **public** name. This catches a backend left on the internet. 2. **From a resource inside your network that is not AuthProxy**, against the backend’s **private** name. This catches a backend that other workloads can reach. On Container Apps this is the check that matters most; see [Container Apps](/authproxy/azure/container-apps/#isolating-backends). Finally, prove the positive path too: sign in through AuthProxy and confirm the application sees you. A backend that nothing can reach passes the test above trivially. *** ## Order of work 1. Register the Entra application and create the secret. 2. Create the network, the storage account and container, and the Key Vault with its key. 3. Deploy the backends and frontends **private** first. 4. Deploy AuthProxy with the settings above and the managed identity’s role assignments. 5. Run the [isolation test](#test-that-a-backend-cannot-be-reached-directly), then sign in. # AuthProxy on Azure App Service > Run AuthProxy on Azure App Service as a Linux custom container and restrict each backend and frontend app so that only AuthProxy can reach it. AuthProxy runs on App Service as a Linux custom container, with your backends and frontends as separate App Service apps (or any other private workload) that only AuthProxy can reach. Read the [overview](/authproxy/azure/) first: it covers the Entra registration and the settings both platforms share. *** ## Create the AuthProxy app Create a Linux App Service plan (Basic or higher; a private endpoint, used below, needs one) and a web app that runs the `cratis/authproxy` image from Docker Hub. Pin a version tag rather than `latest`. ```bash az appservice plan create --resource-group $rg --name $plan --is-linux --sku P1V3 az webapp create --resource-group $rg --plan $plan --name $authproxy \ --deployment-container-image-name docker.io/cratis/authproxy: az webapp identity assign --resource-group $rg --name $authproxy az webapp update --resource-group $rg --name $authproxy --https-only true az webapp config set --resource-group $rg --name $authproxy --web-sockets-enabled true --always-on true ``` * **`WEBSITES_PORT=8080`.** App Service assumes a container listens on port 80. The AuthProxy image listens on 8080, so tell the platform. * **HTTPS only**, so plain-HTTP requests are redirected before they reach AuthProxy. * **Web sockets on**, if any service behind AuthProxy uses WebSockets. * **Always On**, so the app is not unloaded after 20 minutes without traffic. The platform’s keep-alive request to `/` is answered `401` by AuthProxy, which does it no harm. * **Managed identity**, for the storage account and Key Vault key used for [Data Protection keys](/authproxy/azure/#data-protection-keys). * **Leave Authentication off**, as the overview explains. ### App settings Everything below is an app setting, which App Service passes to the container as an environment variable. `__` in a name is the configuration separator. Keep the client secret out of plain settings by referencing a Key Vault secret, which the app’s managed identity must be allowed to read: ```bash az webapp config appsettings set --resource-group $rg --name $authproxy --settings \ WEBSITES_PORT=8080 \ Cratis__AuthProxy__Authentication__OidcProviders__0__Name=Microsoft \ Cratis__AuthProxy__Authentication__OidcProviders__0__Type=Microsoft \ Cratis__AuthProxy__Authentication__OidcProviders__0__Authority=https://login.microsoftonline.com/$directoryTenantId/v2.0 \ Cratis__AuthProxy__Authentication__OidcProviders__0__ClientId=$clientId \ "Cratis__AuthProxy__Authentication__OidcProviders__0__ClientSecret=@Microsoft.KeyVault(SecretUri=$secretUri)" \ Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__ProviderKey=entra-workforce \ Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__SubjectClaimType=oid \ Cratis__AuthProxy__Services__portal__Backend__BaseUrl=https://$backend.azurewebsites.net/ \ Cratis__AuthProxy__Services__portal__Frontend__BaseUrl=https://$frontend.azurewebsites.net/ \ Cratis__AuthProxy__Services__portal__AnonymousPaths__0=/api/health \ Cratis__AuthProxy__Ingress__Mode=Configured \ Cratis__AuthProxy__Ingress__TrustedProxies__0=10.0.0.0/8 \ Cratis__AuthProxy__Ingress__TrustedProxies__1=172.16.0.0/12 \ Cratis__AuthProxy__Ingress__TrustedProxies__2=192.168.0.0/16 \ Cratis__AuthProxy__Ingress__ForwardLimit=1 \ Cratis__AuthProxy__DataProtection__Store=AzureBlob \ Cratis__AuthProxy__DataProtection__AzureBlob__BlobUri=https://$account.blob.core.windows.net/dataprotection/authproxy-keys.xml \ Cratis__AuthProxy__DataProtection__KeyVault__KeyIdentifier=https://$vault.vault.azure.net/keys/authproxy-dataprotection ``` Add the `Authorization` settings from the overview if you require an app role. The trusted-proxy and `AnonymousPaths` settings are explained next. ### Trusted proxies App Service terminates TLS at its front ends and reaches your container from a private address. Microsoft’s own ASP.NET Core guidance for App Service trusts the three private ranges, `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`, because the platform does not publish anything narrower. The settings above do the same, with a `ForwardLimit` of `1` for the App Service front end alone. Two consequences: * In multitenant App Service, only the platform front ends connect to the container. The private ranges also include your own virtual network, so do not assume that boundary in an App Service Environment or when AuthProxy has a private endpoint: a peer that can reach the worker directly may supply trusted forwarded headers. Restrict access to the **AuthProxy app**, not just its backends. Use access restrictions for its public ingress; private-endpoint traffic bypasses those rules and needs network controls such as a subnet network security group with private-endpoint network policies enabled. * Put Azure Front Door or Application Gateway in front of the app and you have a second hop. Raise `ForwardLimit` to `2` and add that service’s own address ranges to `TrustedProxies`, because every hop that is consumed has to be trusted. For Front Door, also allow only your instance to reach AuthProxy, with an access restriction on the `AzureFrontDoor.Backend` service tag and your `X-Azure-FDID` header value; otherwise anyone can bypass it and talk to the app’s public name. After the first sign-in, check what AuthProxy recorded. The `ipAddress` your [sign-in notification](/authproxy/configuration/sign-in/) receives should be the user’s, not an address inside Azure. [Trusted proxies](/authproxy/configuration/trusted-proxies/#checking-your-work) shows how to read it. ### Health check App Service’s health check requests a path on the site and takes an instance out of rotation when it does not answer `200` to `299`. It does not follow redirects, and a site with its own authentication must let the path through anonymously. AuthProxy’s own endpoints are no use to it: `/` answers `401`, and the [management listener](/authproxy/configuration/management-listener/) is on a second port, while App Service sends traffic to the one port named by `WEBSITES_PORT`. Point the check at a health endpoint of your **backend**, made reachable without a session by declaring it anonymous. That is the `AnonymousPaths__0=/api/health` setting above; a path under `/api` goes to the service’s backend. It tests AuthProxy and the backend end to end, so an unhealthy backend takes the instance out of rotation. Declare the narrowest path and make sure it returns nothing sensitive; in a deployment with several services, a declared prefix is [claimed for the whole proxy](/authproxy/configuration/services/#what-it-does-and-does-not-change). Enable **Health check** under **Monitoring** on the AuthProxy app, give it that path, and run at least two instances, because App Service does not take its only instance out of rotation. ### Timeouts and streaming AuthProxy cancels a request that is idle for five minutes by default (`Cratis__AuthProxy__ActivityTimeout`). App Service’s front end also applies an idle limit of its own to a connection; look up its current value in the App Service documentation, because AuthProxy cannot raise it. Whichever limit is shorter ends a quiet WebSocket or Server-Sent Events stream, so send a heartbeat every 15 to 30 seconds and treat a dropped stream as normal on the client. See [Timeouts and streaming](/authproxy/configuration/services/#timeouts-and-streaming). *** ## Isolating backends Each backend and frontend is its own App Service app, and **each one must refuse everything except AuthProxy.** There are two ways; pick one per app. The first is the stronger. **Prerequisite for both alternatives:** put AuthProxy on a **virtual network integration subnet** before configuring any backend. This is outbound-only; it does not make AuthProxy private. ```bash az webapp vnet-integration add --resource-group $rg --name $authproxy \ --vnet $vnet --subnet integration-subnet ``` The subnet must be delegated to `Microsoft.Web/serverFarms` (the command applies the delegation if it is missing) and cannot be the subnet that holds a private endpoint. ### Private endpoint (recommended) The backend gets a private IP in your virtual network and no public address. AuthProxy reaches it over its own virtual network integration. 1. Create a **private endpoint** for each backend in a separate subnet, and a private DNS zone `privatelink.azurewebsites.net` linked to the virtual network, so that `$backend.azurewebsites.net` resolves to the private address from inside the network: ```bash az network private-dns zone create --resource-group $rg --name privatelink.azurewebsites.net az network private-dns link vnet create --resource-group $rg --zone-name privatelink.azurewebsites.net \ --name authproxy-link --virtual-network $vnet --registration-enabled false az network private-endpoint create --resource-group $rg --name $backend-pe \ --vnet-name $vnet --subnet private-endpoint-subnet \ --private-connection-resource-id $(az webapp show --resource-group $rg --name $backend --query id -o tsv) \ --group-id sites --connection-name $backend-pe-connection az network private-endpoint dns-zone-group create --resource-group $rg \ --endpoint-name $backend-pe --name default \ --private-dns-zone privatelink.azurewebsites.net --zone-name privatelink.azurewebsites.net ``` 2. **Disable public network access** on the backend. This is the step that makes it isolated; without it the backend keeps answering on its public name as well. ```bash az resource update --resource-group $rg --name $backend --resource-type Microsoft.Web/sites \ --set properties.publicNetworkAccess=Disabled ``` On Linux, changing this property restarts the app. AuthProxy’s `BaseUrl` stays `https://$backend.azurewebsites.net/`: the name must match the app’s own, and inside the virtual network it now resolves to the private endpoint. Remember what a private endpoint does not do: **access restriction rules are not evaluated for traffic through it.** Anything that can route to the endpoint’s address, including other workloads in the network and peered networks, reaches the backend. Either keep the network to workloads you trust as much as AuthProxy, or restrict who may connect to the endpoint with a network security group on its subnet (private endpoint network policies must be enabled on the subnet for the group to apply). Then run the second [isolation test](/authproxy/azure/#test-that-a-backend-cannot-be-reached-directly) from another workload in the network to see which of those you actually have. ### Access restrictions with a service endpoint If a private endpoint is not an option, keep the public name but allow only AuthProxy’s integration subnet. ```bash # Service endpoints for Microsoft.Web must be enabled on the integration subnet. az network vnet subnet update --resource-group $rg --vnet-name $vnet --name integration-subnet \ --service-endpoints Microsoft.Web az webapp config access-restriction add --resource-group $rg --name $backend \ --rule-name allow-authproxy --action Allow --priority 100 \ --vnet-name $vnet --subnet integration-subnet az webapp config access-restriction add --resource-group $rg --name $backend \ --rule-name allow-authproxy --action Allow --priority 100 \ --vnet-name $vnet --subnet integration-subnet --scm-site true ``` Adding any rule makes the end of the list an implicit **deny all**, so nothing else is needed. Do the same for the advanced tools (`scm`) site as shown, because it has its own rule list. Use the subnet rule rather than an IP address: in the multitenant App Service an IP rule cannot name a virtual network range, and AuthProxy’s public outbound addresses are shared with other apps on the same workers. Any app integrated with that subnet is allowed through, so keep untrusted apps off it. ### Prove it First confirm that your chosen endpoint returns `2xx` for the probe’s forged principal from an allowed location, as the [direct-access test](/authproxy/azure/#test-that-a-backend-cannot-be-reached-directly) explains. Then run it against that endpoint at `https://$backend.azurewebsites.net/` from a machine outside the network: it must be refused. Identify any expected platform HTTP refusal with both its status and distinctive body marker; an application `403` or `404` is not a pass. Run it again from a virtual machine or app in the virtual network that is not AuthProxy. With access restrictions it is refused. With a private endpoint it is refused only if you restricted the endpoint as described above; otherwise it is answered, and so would be an attacker’s request from that network. Then sign in through AuthProxy and confirm the application works. # AuthProxy on Azure Container Apps > Run AuthProxy on Azure Container Apps with external ingress in front of internal-ingress backends, and isolate those backends from other apps. AuthProxy runs on Container Apps as one container app with **external** ingress, in front of backends and frontends that are container apps with **internal** ingress. Read the [overview](/authproxy/azure/) first: it covers the Entra registration and the settings both platforms share. The decision that matters here is how much you trust the other apps in the environment. Internal ingress does not mean “reachable only by AuthProxy”. See [Isolating backends](#isolating-backends). *** ## Create the apps Use a **user-assigned managed identity** for AuthProxy. Its Key Vault secret reference and its storage and vault role assignments all have to exist before the app starts, and a system-assigned identity does not exist until the app has been created. ```bash az identity create --resource-group $rg --name id-authproxy identityId=$(az identity show --resource-group $rg --name id-authproxy --query id -o tsv) identityClientId=$(az identity show --resource-group $rg --name id-authproxy --query clientId -o tsv) ``` Give the identity the `Key Vault Secrets User` role on the client secret, the `Key Vault Crypto User` role on the Data Protection key, and `Storage Blob Data Contributor` on the blob container. Then create AuthProxy: ```bash az containerapp create --resource-group $rg --name authproxy --environment $env \ --image docker.io/cratis/authproxy: \ --user-assigned $identityId \ --ingress external --target-port 8080 \ --min-replicas 2 \ --secrets "entra-client-secret=keyvaultref:$secretUri,identityref:$identityId" \ --env-vars \ Cratis__AuthProxy__Authentication__OidcProviders__0__Name=Microsoft \ Cratis__AuthProxy__Authentication__OidcProviders__0__Type=Microsoft \ Cratis__AuthProxy__Authentication__OidcProviders__0__Authority=https://login.microsoftonline.com/$directoryTenantId/v2.0 \ Cratis__AuthProxy__Authentication__OidcProviders__0__ClientId=$clientId \ Cratis__AuthProxy__Authentication__OidcProviders__0__ClientSecret=secretref:entra-client-secret \ Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__ProviderKey=entra-workforce \ Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__SubjectClaimType=oid \ Cratis__AuthProxy__Services__portal__Backend__BaseUrl=https://$backendFqdn/ \ Cratis__AuthProxy__Services__portal__Frontend__BaseUrl=https://$frontendFqdn/ \ Cratis__AuthProxy__Ingress__Mode=TrustAny \ Cratis__AuthProxy__Ingress__ForwardLimit=1 \ Cratis__AuthProxy__DataProtection__Store=AzureBlob \ Cratis__AuthProxy__DataProtection__AzureBlob__BlobUri=https://$account.blob.core.windows.net/dataprotection/authproxy-keys.xml \ Cratis__AuthProxy__DataProtection__KeyVault__KeyIdentifier=https://$vault.vault.azure.net/keys/authproxy-dataprotection \ Cratis__AuthProxy__DataProtection__ManagedIdentityClientId=$identityClientId \ Cratis__AuthProxy__Management__Port=9110 \ Cratis__AuthProxy__Management__BindAddress=0.0.0.0 ``` * **Target port 8080.** The AuthProxy image listens on 8080. Ingress publishes it on 443 and redirects port 80 to it. * **At least two replicas**, and a shared key store, so a restart or scale-out does not sign users out. With the shared [Data Protection keys](/authproxy/azure/#data-protection-keys), any replica can read any session. Leave [session affinity](https://learn.microsoft.com/azure/container-apps/sticky-sessions) off. * **The client secret** is a Key Vault reference resolved by the managed identity and passed to the container as `secretref:`. It never appears in the app’s settings. * **Leave Authentication off** on the container app, as the overview explains. ### Service addresses Use the backend’s **internal FQDN**, which is `.internal...azurecontainerapps.io`: ```bash backendFqdn=$(az containerapp show --resource-group $rg --name portal-api \ --query properties.configuration.ingress.fqdn -o tsv) ``` Container Apps also documents `http://` for calls inside an environment. With the default ingress setting (`allowInsecure` is `false`) plain HTTP is redirected to HTTPS, and AuthProxy passes a redirect from a backend on to the caller rather than following it, so use the `https` FQDN and keep the traffic encrypted. ### Trusted proxies Container Apps ingress overwrites `X-Forwarded-Proto` and adds the client’s address to `X-Forwarded-For`, and Microsoft’s documentation states that only the rightmost address is provided by the platform. AuthProxy takes only that one with a `ForwardLimit` of `1`, so a value a caller wrote further left in the header is never read. The address AuthProxy sees as the peer is the environment’s ingress proxy, and the platform publishes no range for it. The settings above therefore use `Mode=TrustAny`, which is right **only because nothing other than the platform’s ingress can connect to the container’s port 8080**: the platform routes every request between apps through its proxy layer, not directly to a replica. If you also run something that can open a connection straight to the replica, switch to `Mode=Configured` and name the real peers with `TrustedProxies` instead. `TrustAny` ignores `TrustedProxies` even when you populate it. See [Trusted proxies](/authproxy/configuration/trusted-proxies/#modes). ### Adding an upstream proxy Putting Azure Front Door or Application Gateway in front of AuthProxy does not make the Container Apps origin private. With external ingress, a caller can still bypass that upstream proxy and call the ACA origin hostname directly. **Keep `ForwardLimit=1` while that origin is publicly reachable in `Mode=TrustAny`.** If you raise it to `2`, a direct caller can send `X-Forwarded-For: 203.0.113.123`; Container Apps appends the real caller address, and AuthProxy consumes both entries and records the forged address in sign-in notifications. Adding upstream ranges to `TrustedProxies` cannot prevent this in `TrustAny` mode. Before increasing the limit, choose one of these boundaries: * Restrict access to AuthProxy’s origin to the intended upstream proxy, including any routes from other apps in the environment. Use a network restriction that prevents bypass; an upstream hostname alone is not an access restriction. Then set the limit to the number of verified forwarded hops. * Use `Mode=Configured` with verified trusted hops in `TrustedProxies`, including the actual Container Apps ingress peer and the upstream proxy. Container Apps publishes no ingress peer range, so do not guess one or use a catch-all range. If you cannot verify every trusted hop, keep `ForwardLimit=1`. **Require a direct-origin spoofed-header check before increasing `ForwardLimit`:** from outside the intended upstream proxy, send `X-Forwarded-For: 203.0.113.123` directly to AuthProxy’s ACA origin hostname, not the Front Door or Application Gateway hostname. With an origin restriction, the platform must refuse that request before it reaches AuthProxy. With `Configured` mode, complete a test sign-in carrying the spoofed header and confirm that the sign-in notification records the actual caller address, never `203.0.113.123`. Also confirm that a normal sign-in through the upstream proxy records the real client address. Test the proposed higher limit in a non-production deployment with the same boundary before applying it in production; do not increase it if the check fails or the recorded address cannot be observed. ### Health probes The management listener is a second port that the platform can probe, so use it: ```yaml containers: - name: authproxy image: docker.io/cratis/authproxy: probes: - type: Liveness httpGet: path: /health/live port: 9110 - type: Readiness httpGet: path: /health/ready port: 9110 ``` Apply it by updating the app with a YAML definition that carries this `probes` list. Readiness runs the Data Protection round trip, so a replica that cannot load the key ring from storage is kept out of rotation. Probe port 9110 matches `Management__Port` above. `Management__BindAddress=0.0.0.0` is needed because the default is loopback, which a probe sent to the replica from outside the container cannot reach. The management port is not the ingress target port, so ingress does not publish it. It answers only `/health/live` and `/health/ready`, with a fixed short body. See [Management listener](/authproxy/configuration/management-listener/). If a replica never becomes ready, check this bind address first. ### Timeouts and streaming Ingress ends a request that is idle for **four minutes** by default (240 seconds). A Container Apps environment on the premium ingress can raise that to up to 30 minutes in its ingress settings. AuthProxy’s own `ActivityTimeout` defaults to five minutes, so on a default environment the platform cuts a quiet WebSocket or Server-Sent Events stream first. Send a heartbeat every 15 to 30 seconds, and raise both limits together if a stream has to be quiet for longer. See [Timeouts and streaming](/authproxy/configuration/services/#timeouts-and-streaming). *** ## Isolating backends Create each backend and frontend container app with **internal** ingress: ```bash az containerapp create --resource-group $rg --name portal-api --environment $env \ --image --ingress internal --target-port 8080 ``` From outside the environment, an internal app’s name resolves and the TLS handshake succeeds, but the environment’s proxy answers `404`. That settles the internet, and for a VNet-integrated environment it settles your own network too: requests from your virtual network count as outside the environment. **It does not settle the other apps in the environment.** Container Apps documents internal ingress as reachable from other container apps in the same environment, and an *external* app is also reachable from them. Any app in the environment can therefore call `https://portal-api.internal...` with a forged `x-ms-client-principal` header and be believed. Treat the environment as the trust boundary: 1. **Run AuthProxy and the apps it protects in an environment of their own**, in which every app is one you would trust with every user’s identity. Put anything else, such as other teams’ apps, jobs and experiments, in a different environment. 2. **Keep the backends out of any environment-level HTTP route configuration.** An app with internal ingress can still receive outside traffic when a route configuration names it as a target. 3. **For an environment that only your network should reach**, create it as an internal environment, and publish AuthProxy with external ingress, which then listens on the environment’s internal load balancer. Container Apps documents the two settings together: the environment decides whether there is a public endpoint, and the app’s ingress decides whether it is published at that boundary. 4. **Do not rely on IP restrictions** on a backend to separate it from apps in the same environment. They filter by caller address, and in-environment traffic arrives through the platform’s proxy layer. Container Apps client certificates (`clientCertificateMode`) would tie a backend to AuthProxy by certificate, but AuthProxy does not present a client certificate to the services it proxies to, so they cannot be used for this today. Per-request signing of the forwarded identity, which would make environment membership irrelevant, is tracked in [Cratis/AuthProxy#139](https://github.com/Cratis/AuthProxy/issues/139). ### Prove it First confirm that your chosen endpoint returns `2xx` for the probe’s forged principal from an allowed location, as the [direct-access test](/authproxy/azure/#test-that-a-backend-cannot-be-reached-directly) explains. Then run it twice: * Against that endpoint at `https://portal-api.internal...azurecontainerapps.io/` from outside the environment, such as your pipeline runner. It must be refused. To accept the platform’s `404`, configure both that status and a distinctive platform-refusal body marker; an application’s own `404` must not pass. * Against the same address from a **container app in the same environment that is not AuthProxy**. It will be answered. That is not a bug in your configuration; it is the boundary described above. If that second app is not supposed to be able to impersonate users, it belongs in another environment. Then sign in through AuthProxy and confirm the application works. # Configuration > Configure AuthProxy through the Cratis:AuthProxy section of appsettings.json or Cratis__AuthProxy__ environment variables, with an overview of the settings. Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section of `appsettings.json` (or equivalent environment variables using the `Cratis__AuthProxy__` prefix). ```json { "Cratis": { "AuthProxy": { "Authentication": { ... }, "Authorization": { ... }, "Admission": { ... }, "TenantResolutions": [ ... ], "TenantVerification": { ... }, "Tenants": { ... }, "Services": { ... }, "Session": { ... }, "SignIn": { ... }, "Logout": { ... }, "Ingress": { ... }, "Invite": { ... }, "Management": { ... }, "ActivityTimeout": "00:05:00", "PagesPath": "", "DataProtectionKeysPath": "", "DataProtection": { ... } } } } ``` | Topic | Description | | --- | --- | | [Authentication](/authproxy/configuration/authentication/) | OIDC providers, OAuth 2.0 providers such as GitHub, JWT Bearer configuration, and bearer routes for access tokens from an external authorization server. | | [Authorization](/authproxy/configuration/authorization/) | Requiring a claim — a role, a group, a GitHub organization or team — before any request is forwarded. | | [Admission](/authproxy/configuration/admission/) | Answering nothing at all until a caller presents a capability your own verifier admits, for a deployment whose existence is not meant to be discoverable. | | [Tenancy](/authproxy/configuration/tenancy/) | How the auth proxy resolves the current tenant from each request, and how to verify tenant existence. | | [Tenant Selection Page](/authproxy/configuration/tenant-selection/) | How selection-based tenant resolution works and how to build/override `select-tenant.html`. | | [Data Protection Keys](/authproxy/configuration/data-protection/) | Sharing the key ring that encrypts sessions and tokens between replicas: a file system, Azure Blob Storage or Redis, with optional Azure Key Vault key protection. | | [Trusted Proxies](/authproxy/configuration/trusted-proxies/) | Which callers may speak for the client through `X-Forwarded-For` and `X-Forwarded-Proto`, and how many hops to follow. | | [Services](/authproxy/configuration/services/) | Routing requests to backend and frontend services, and the idle timeout that applies to proxied requests, WebSockets and SSE streams. | | [One Proxy, Several Hosts](/authproxy/configuration/one-proxy-several-hosts/) | Giving each application its own host behind one AuthProxy and one sign-in, with a shared session cookie and a single identity-provider callback. | | [Management Listener](/authproxy/configuration/management-listener/) | An opt-in private listener carrying liveness and readiness endpoints, so a probe tests more than “a process accepted a socket”. | | [Lobby](/authproxy/configuration/lobby/) | Invite and registration flows that hand users off to the lobby experience. | | [Well-Known Pages](/authproxy/configuration/well-known-pages/) | Built-in HTML pages (provider selection, errors, tenant not found) and how to override them via a mounted volume. | # Admission > Admission makes an AuthProxy deployment closed, so every caller without a valid capability gets an identical 404 and the sign-in surface stays undiscoverable. AuthProxy’s sign-in surface is public by design. The provider list at `/.cratis/providers`, the per-provider challenge endpoints, the selection page and every asset behind it all answer a caller who has never signed in — because a person who has never signed in has to reach them in order to sign in at all. That is right for a deployment whose front door is meant to be found. It is wrong for one whose *existence* is not meant to be discoverable: the provider list names the identity providers the organization trusts, the challenge endpoint confirms whether a named provider is configured, and every one of them says an AuthProxy is here. **Admission** is the switch between those two deployments. In `CapabilityOnly` mode nothing at all is answered until a caller presents a capability the deployment’s own verifier admits. *** ## The shape of it ``` Your serviceYour verifierAuthProxyCallerYour serviceYour verifierAuthProxyCallerGET /anything404 Not FoundPOST /.cratis/admission (capability in the body)POST {capability, transaction, challenge}{admitted, transaction, challenge}204 + entry cookieGET /anything (with the entry cookie)forwarded, exactly as it always was ``` Everything downstream of the gate is unchanged. An admitted caller meets exactly the authentication, authorization, tenancy and routing they would have met with admission switched off — admission decides whether there is anything here to reach, not who may reach it. *** ## Turning it on Name a mode and a verifier. Everything else has a default. ```json { "Cratis": { "AuthProxy": { "Admission": { "Mode": "CapabilityOnly", "Capability": { "VerifierUrl": "https://members.example.com/admit" } } } } } ``` With Aspire: ```csharp builder.AddAuthProxy("authproxy") .WithCapabilityOnlyAdmission("https://members.example.com/admit"); ``` **Leaving the section out changes nothing.** The default mode is `Public`, which is what every release before this behaved as. This is an opt-in posture, not a hardening of the default — turning it on closes a contract that other deployments depend on being open. *** ## Settings | Setting | Default | Meaning | | --- | --- | --- | | `Mode` | `Public` | `Public` or `CapabilityOnly`. Anything else is refused at startup. | | `Capability:VerifierUrl` | *(none)* | The absolute `http`/`https` URL that decides whether a presented capability admits. **Required** under `CapabilityOnly`. | | `Capability:Path` | `/.cratis/admission` | The one path a capability may be presented on. | | `Capability:MaximumLength` | `4096` | The largest capability, in bytes, AuthProxy will read from a presentation. | | `EntryLifetime` | `00:20:00` | How long an admitted browser stays admitted. | There is deliberately **no default verifier URL**. A verifier is your own service, and inventing an address for it would mean a misconfigured deployment silently calling something else. *** ## The verifier contract This is the whole of what a deployment has to implement. AuthProxy is deliberately not the authority on what a capability means — it reads a value, carries it here, and does what it is told, so issuance, revocation, single use, expiry and every other rule stay entirely on your side of the call. ### The request `POST` to `VerifierUrl`, `Content-Type: application/json`: ```json { "capability": "the exact value the caller presented, uninterpreted", "transaction": "3f9c0a1b7e2d4c6f4a2e8d1c0b7a6934…", "challenge": "8b1d5e7a0c3f29417d6b4e2a9c8f0135…" } ``` `transaction` and `challenge` are two independent 256-bit values AuthProxy mints per presentation and never reuses. They are opaque to you. ### The reply `200 OK`, `Content-Type: application/json`: ```json { "admitted": true, "transaction": "3f9c0a1b7e2d4c6f4a2e8d1c0b7a6934…", "challenge": "8b1d5e7a0c3f29417d6b4e2a9c8f0135…" } ``` **Echo both values back exactly**, or the answer is refused. A reply that does not name the exact presentation it belongs to is not an answer to it, and treating it as one would let a reply meant for some other presentation admit this caller. The comparison is fixed-time. Any other field you send is read past and ignored. Nothing you return is stored, forwarded or sealed into the browser — the entry cookie carries the two values AuthProxy authored and an expiry, and nothing else, so its size is one you cannot influence. ### Every other outcome is a refusal | What your verifier did | What AuthProxy does | | --- | --- | | `{"admitted": true}` echoing both values | Admits | | `{"admitted": false}` | Refuses | | Echoed the wrong transaction or challenge | Refuses | | Answered a non-`2xx` status | Refuses | | Answered a body that will not parse | Refuses | | Answered with a redirect | Refuses — the redirect is **not** followed | | Could not be reached | Refuses | | Took longer than **5 seconds** | Refuses | | Threw anything at all | Refuses | Failing closed is the point: a verifier outage that let callers in would make the whole mode worth nothing. The corollary is that **a verifier outage is a full outage** — nobody new is admitted while it lasts, though browsers already holding a live entry keep working until it expires. The redirect row is deliberate. `VerifierUrl` is constrained at startup to one absolute `http`/`https` URL, and following a `3xx` would hand that constraint back to whoever answers it — an anonymous POST would become an AuthProxy-originated POST to any internally reachable host, carrying the caller’s capability in the body. *** ## What the caller does One `POST`, with the capability as the **raw request body**: ```bash curl -i -X POST https://app.example.com/.cratis/admission \ --data-binary 'the-capability-value' ``` ```http HTTP/1.1 204 No Content Set-Cookie: .cratis-entry=…; max-age=1200; path=/; samesite=lax; httponly; secure ``` The capability is read from the body and **from nowhere else** — not a path segment, not a query parameter, not a header, not a cookie. Every one of those is a place a value ends up written into an access log, a proxy’s cache key or a browser’s history, and a bearer value that admits a caller has no business in any of them. The answer carries no body. What the browser needs is the cookie. ### Anything else is the uniform refusal A `GET` to the presentation path, a body over `MaximumLength`, an empty body, a capability the verifier refused, a verifier that never answered — all of them produce the identical `404`, the same one an unknown path produces. The endpoint is not a probe for whether the mode is on. *** ## Entry lifetime `EntryLifetime` bounds one interactive entry — choosing a provider, completing the round-trip to the identity provider, and coming back — not a session. The session cookie the sign-in produces has its own, much longer, lifetime and is unaffected. The default is **twenty minutes**, and the number is not arbitrary. ASP.NET Core’s own `RemoteAuthenticationOptions.RemoteAuthenticationTimeout` allows **fifteen minutes** at the provider, and enrolling in MFA, resetting a password or working through a consent screen routinely uses them. An entry shorter than that expires while the framework still considers the handshake live, and the caller comes back to a `404` with no way to recover and — by design — nothing in any response or log to diagnose it from. **Do not set `EntryLifetime` below fifteen minutes.** If you shorten it, shorten `RemoteAuthenticationTimeout` to match. When an entry does expire, the caller presents a new capability. That is the recovery path, and it is worth making sure whoever hands out capabilities can hand out another one. *** ## What a refusal looks like Every refusal is the same refusal, byte for byte: ```http HTTP/1.1 404 Not Found Content-Length: 9 Content-Type: text/plain; charset=utf-8 Cache-Control: no-store Not Found ``` No `Server` header, no `WWW-Authenticate`, no `Allow`, no `Location`, no `Set-Cookie`, no branded error page. Response headers queued by anything upstream are cleared rather than added to, so a challenge or a cookie an earlier decision produced cannot survive into a refusal and make it distinguishable. Every route and every method answers this — including the paths AuthProxy itself owns, and including a management-listener path offered to the public listener. ### What it does not hide The claim is that the **content** of the answers is indistinguishable, not that every observable is. * **Timing.** A presentation on `Capability:Path` costs a verifier round-trip that no other path costs — on loopback, roughly 1.1 ms against 0.5 ms, and up to the full five-second budget when the verifier hangs. The presentation path is therefore discoverable by timing. * **`MaximumLength`.** An over-length body is refused *before* the round-trip starts, so the configured bound is discoverable by binary search against that timing difference. Neither is addressed, and deliberately so: closing them would mean padding every refusal to the slowest one, which buys an attacker learning where a path is that already answers nothing, at a real cost in availability. Treat an unguessable `Capability:Path` as one less thing to notice, never as the control. *** ## What an operator should expect to observe The hardest part of running a closed deployment is that it is designed to say nothing, so know in advance what the signals are. | Symptom | What it means | | --- | --- | | Every request answers `404`, including yours | Working as configured. Present a capability. | | The process refuses to start, naming `Admission:Capability:VerifierUrl` | The mode is on and no verifier is named. This is the one misconfiguration that cannot announce itself later. | | The process refuses to start, naming `Admission:Mode` | The mode is neither `Public` nor `CapabilityOnly`. A value outside the enum binds silently, so it is refused rather than treated as either. | | A presentation answers `404` instead of `204` | The verifier refused, was unreachable, timed out, or answered about a different presentation. Which one is in **your log**, at `Warning`. | | Admission worked, then everything answers `404` again | The entry expired, or the deployment’s Data Protection key ring changed. | AuthProxy’s own logs are deliberately parameterless — a capability is a bearer value and the presentation path names the deployment’s closed door, so neither ever reaches a log sink. The four messages are: * `Debug` — a presented capability was refused by the verifier. * `Warning` — the verifier could not be reached, or did not answer in time. * `Warning` — the verifier answered about a different presentation than the one it was asked about. * `Error` — no verifier is configured, so nothing can be admitted. The detail you need for a specific presentation is on the verifier’s side, where you own it. ### Data Protection The entry cookie is sealed with the application’s ASP.NET Core Data Protection key ring, under a purpose of its own. A deployment that does not persist its keys issues a new key ring on every restart, which invalidates every outstanding entry — set [`DataProtectionKeysPath`](/authproxy/configuration/authentication/) to a persistent volume, and share it across replicas, or every instance will refuse the entries the others issued. *** ## What this changes elsewhere ### `AnonymousPaths` do not apply A path a service declares in [`AnonymousPaths`](/authproxy/configuration/services/#anonymous-paths) is still closed to an unadmitted caller. That is not an oversight — the whole point of the mode is that nothing is answered before admission, and `AnonymousPaths` is a statement about *authentication*, one layer further in. For a deployment that needs a genuinely public surface *and* a closed one, run two deployments. See [Public application surfaces](/authproxy/configuration/public-surfaces/). ### Unauthenticated responses do not apply either The `401`/`403`/selection-page rules in [Unauthenticated responses](/authproxy/configuration/unauthenticated-responses/) describe what happens *after* admission. To an unadmitted caller there is only the uniform `404`. This includes probes. A liveness or readiness probe pointed at any application path gets `404` on a closed deployment — use the [management listener](/authproxy/configuration/management-listener/), whose port is separate and whose endpoints are not gated by admission. ### `Invite` cannot be combined with it `CapabilityOnly` and an `Invite` section together are refused at startup. Two capability mechanisms in one deployment is a misconfiguration: an invitation is a capability with its own issuance, its own browser state and its own refusals, and it would be reached only through a door admission has already closed. Refusing the combination keeps the option of unifying them later open, rather than freezing whichever precedence happened to ship. ### The token endpoint needs an entry too `/.cratis/token` is mapped when any service declares client credentials — but on a closed deployment it sits behind the gate like everything else, so a machine client has to present a capability first and send the resulting entry cookie with its token request. That is a two-step nobody expects from an OAuth token endpoint. If your closed deployment serves machine clients, either give them a capability as part of provisioning, or front them with a deployment left in `Public` mode. A closed deployment where no service declares client credentials does not get the endpoint at all. *** ## What is refused at startup Each of these would otherwise start cleanly and then refuse every caller alive, with a `404` that says nothing about why: | Configuration | Why it is refused | | --- | --- | | `Mode` outside the enum (including a number) | A name the binder cannot parse already refuses to start; a number binds silently and would leave the gate inert or the deployment closed with nothing checked. | | `CapabilityOnly` with no `VerifierUrl` | Nothing can ever be admitted. | | A `VerifierUrl` that is not an absolute `http`/`https` URL | An absolute filesystem path parses as an absolute URI on Unix, so the scheme is named rather than merely parsed. | | A `Capability:Path` that is not rooted | Matches no request, so nothing can ever be presented. | | `Capability:MaximumLength` of zero or less | A bound of nothing refuses every capability. | | `EntryLifetime` of zero or less | An entry that has expired before it is issued admits nobody. | | `CapabilityOnly` together with `Invite` | Two capability mechanisms in one deployment. | # Authentication > Configure AuthProxy authentication: OpenID Connect cookie sessions, JWT Bearer tokens, back-channel client credentials, and OAuth providers such as GitHub. AuthProxy supports three authentication modes that can be active simultaneously: * **Interactive browser sessions** – OpenID Connect (OIDC) with a cookie. * **Machine-to-machine / API** – JWT Bearer tokens from an external identity provider. * **Back-channel client credentials** – service-owned client credentials verified by the target service itself. *** ## OIDC providers Configure one or more OpenID Connect providers under `Cratis:AuthProxy:Authentication:OidcProviders`. **Single provider** – the auth proxy challenges unauthenticated browser requests directly with that provider: ```json { "Cratis": { "AuthProxy": { "Authentication": { "OidcProviders": [ { "Name": "Microsoft", "Type": "Microsoft", "Authority": "https://login.microsoftonline.com//v2.0", "ClientId": "", "ClientSecret": "" } ] } } } } ``` **Multiple providers** – the auth proxy redirects unauthenticated browser requests to a built-in provider-selection page (`/.cratis/select-provider`) so the user can choose which provider to log in with: ```json { "Cratis": { "AuthProxy": { "Authentication": { "OidcProviders": [ { "Name": "Microsoft", "Type": "Microsoft", "Authority": "https://login.microsoftonline.com//v2.0", "ClientId": "", "ClientSecret": "", "Scopes": [] }, { "Name": "Google", "Type": "Google", "Authority": "https://accounts.google.com", "ClientId": "", "ClientSecret": "", "Scopes": [] } ] } } } } ``` Each provider generates a dedicated login endpoint at `/.cratis/login/{scheme}`. The scheme name is derived from the provider `Name` by lowercasing and replacing spaces with hyphens (e.g. `"My Provider"` → `/.cratis/login/my-provider`). Both behaviors above — the direct challenge and the selection page — apply to **browser navigations**. A caller that is not navigating to a page is refused with `401` instead, so the rejection is visible to a client that checks the status code. See [Unauthenticated responses](/authproxy/configuration/unauthenticated-responses/). ### Tenant-aware authentication state When authentication starts from a tenant-scoped request, AuthProxy stores tenant resolution metadata in the protected authentication `state` value: * Tenant ID * Tenant resolution strategy * Strategy-specific metadata (for `SubHost`, the configured `ParentHost`) On callback (`/signin-{scheme}`), AuthProxy reads this state and re-applies strategy behavior before finishing sign-in. For `SubHost`, AuthProxy reconstructs the tenant URL and redirects back to that tenant host. Example flow: 1. Request arrives at `https://some-tenant.cratis.studio/` 2. AuthProxy resolves tenant `some-tenant` via `SubHost` 3. Challenge is sent with protected state containing tenant metadata 4. Provider redirects back to `https://auth.cratis.studio/signin-github?...&state=...` 5. AuthProxy restores tenant metadata from state 6. AuthProxy redirects to `https://some-tenant.cratis.studio/` (original return URL preserved) This allows a common callback endpoint while still restoring tenant-specific behavior after sign-in. ### OidcProviderConfig properties | Property | Type | Description | | --- | --- | --- | | `Name` | `string` | Display name shown on the login selection page. | | `Type` | `string` | Provider type hint (`Microsoft`, `Google`, or `Custom`). | | `Authority` | `string` | OIDC authority URL. | | `ClientId` | `string` | OAuth 2.0 client ID. | | `ClientSecret` | `string` | OAuth 2.0 client secret. Leave empty when `ClientCredential` selects a certificate or federated credential. | | `ClientCredential` | `object` | Optional certificate or federated credential used instead of `ClientSecret`. See [Client credentials](#client-credentials-certificates-and-federated-credentials). | | `Scopes` | `string[]` | Additional scopes to request (beyond `openid`, `profile`, `email`). | | `ResponseMode` | `string` | How the provider returns the authorization code: `Query` (default) or `FormPost`. See below. | #### Response mode and the handshake cookies The handshake only completes when the provider callback carries the correlation cookie, and that cookie is `SameSite=Lax` — which a browser attaches to a top-level GET and withholds from a cross-site POST. The default `Query` response mode therefore has the provider return the authorization code in a top-level GET redirect, and the handshake cookies stay `Lax`. Some providers mandate a form POST callback — Apple whenever the `name` or `email` scopes are requested. Setting `ResponseMode` to `FormPost` opts that provider into it, which also switches that provider’s correlation and nonce cookies to `SameSite=None; Secure` — a cross-site POST only ever carries `None` cookies, and `None` requires HTTPS. Do not choose `FormPost` for providers that support `Query`; it trades away the `Lax` hardening for nothing. #### Client credentials: certificates and federated credentials By default AuthProxy authenticates to a provider’s token endpoint with `ClientSecret`. Many organizations disallow long-lived client secrets, and Microsoft recommends certificates or workload identity federation for Microsoft Entra ID confidential clients. Set `ClientCredential` on the provider to authenticate with a `client_assertion` ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)) instead. Leave `ClientSecret` empty: AuthProxy refuses to start when both are configured. AuthProxy presents the credential during authorization-code redemption at the provider’s token endpoint and in pushed authorization requests at the provider’s PAR endpoint when the provider supports them. The credential loaders come from [Microsoft.Identity.Web](https://github.com/AzureAD/microsoft-identity-web/wiki/Certificates), so certificate stores, Key Vault, workload identity and managed identity behave as they do in any other Microsoft.Identity.Web application. | `Source` | Credential | Required properties | | --- | --- | --- | | `ClientSecret` | `ClientSecret` sent as `client_secret` (the default). | — | | `CertificateFile` | A PKCS#12 (`.pfx`) file with the private key signs the assertion. | `CertificatePath`; `CertificatePassword` when the file has one. | | `CertificateStore` | A certificate found by thumbprint in a certificate store signs the assertion. | `CertificateThumbprint`; `CertificateStorePath` defaults to `CurrentUser/My`. | | `KeyVaultCertificate` | A certificate downloaded from Azure Key Vault signs the assertion. | `KeyVaultUrl` (https), `KeyVaultCertificateName`. | | `FederatedTokenFile` | A platform-issued federated token read from a file is the assertion (Kubernetes workload identity). | `TokenFilePath`, or the `AZURE_FEDERATED_TOKEN_FILE` environment variable. | | `ManagedIdentity` | An Azure managed identity token for the token-exchange audience is the assertion. | None. `ManagedIdentityClientId` selects a user-assigned identity. | A certificate assertion is a short-lived JWT signed with `RS256` (RSA keys), or `ES256`, `ES384` or `ES512` (ECDSA P-256, P-384 or P-521 keys respectively). Its issuer and subject are `ClientId`, its audience is the provider’s token endpoint, and its header carries the certificate thumbprint (`x5t`). Upload the certificate’s public part to the app registration. AuthProxy loads a certificate once and loads it again after it expires. For `CertificateFile` or `KeyVaultCertificate`, put the renewed certificate in the same file or vault entry before the old one expires, or restart AuthProxy to pick it up straight away. If the replacement is still expired, sign-in fails and AuthProxy retries loading at most once per minute. For `CertificateStore`, renewal changes the thumbprint: update `CertificateThumbprint` to the new certificate’s thumbprint and restart AuthProxy. `KeyVaultCertificate` authenticates to Key Vault with the default Azure credential chain. Set `ManagedIdentityClientId` (or `AZURE_CLIENT_ID`) to use a user-assigned managed identity. The identity needs both certificate-get and secret-get permissions (for example, the Key Vault Certificate User and Key Vault Secrets User roles), because the loader reads the certificate and the secret containing its private key. The certificate must have an exportable private key; a non-exportable Key Vault certificate cannot sign client assertions in AuthProxy. `FederatedTokenFile` and `ManagedIdentity` need a federated identity credential on the app registration that trusts the platform issuer: the cluster’s OIDC issuer and service account for workload identity, or the managed identity. `ManagedIdentity` requests its token for `api://AzureADTokenExchange` (or the national-cloud equivalent resolved from `Authority`). Set `TokenExchangeAudience` to override it. **Certificate from Key Vault:** ```json { "Cratis": { "AuthProxy": { "Authentication": { "OidcProviders": [ { "Name": "Microsoft", "Type": "Microsoft", "Authority": "https://login.microsoftonline.com//v2.0", "ClientId": "", "ClientCredential": { "Source": "KeyVaultCertificate", "KeyVaultUrl": "https://.vault.azure.net", "KeyVaultCertificateName": "authproxy-client" } } ] } } } } ``` **Managed identity on Azure Container Apps or App Service:** ```json { "Cratis": { "AuthProxy": { "Authentication": { "OidcProviders": [ { "Name": "Microsoft", "Type": "Microsoft", "Authority": "https://login.microsoftonline.com//v2.0", "ClientId": "", "ClientCredential": { "Source": "ManagedIdentity", "ManagedIdentityClientId": "" } } ] } } } } ``` With environment variables, the same settings are `Cratis__AuthProxy__Authentication__OidcProviders__0__ClientCredential__Source=ManagedIdentity` and so on. If the credential cannot be loaded or produces no assertion during authorization-code redemption, the sign-in is handled as a [failed sign-in](/authproxy/configuration/failed-sign-ins/). AuthProxy logs credential-loading and assertion-provider errors with the provider and credential source. A credential failure during a pushed authorization request happens while starting the sign-in challenge, outside the callback’s failed-sign-in handling, and returns an HTTP 500 response instead. OAuth 2.0 providers (below) still authenticate with `ClientSecret` only. ### Canonical federated identity Provider registrations can opt into a stable, provider-aware account tuple. Without this section, AuthProxy preserves its legacy claim-selection and payload behavior, except that the reserved `urn:cratis:identity:*` namespace is always removed from fresh legacy callbacks before session storage. ```json { "Cratis": { "AuthProxy": { "Authentication": { "OidcProviders": [ { "Name": "Microsoft Entra", "Type": "Microsoft", "Authority": "https://login.microsoftonline.com//v2.0", "ClientId": "", "ClientSecret": "", "CanonicalIdentity": { "ProviderKey": "entra-workforce", "SubjectClaimType": "oid" } } ] } } } } ``` | Property | Applies to | Contract | | --- | --- | --- | | `InvitationCompletionEnabled` | OIDC and OAuth | Opts this provider into signed invitation completion. Defaults to `false`; ordinary sign-in remains available. Enable only when the provider can produce the exact verified email evidence below. | | `InvitationIdentityBindingCompletionEnabled` | OIDC and OAuth | Opts this provider into signed invitations already bound by the invitation issuer to an immutable provider subject. Defaults to `false`. This mode does not infer email ownership; enable it only for a tenant-scoped immutable subject and framework-validated issuer. | | `ProviderKey` | OIDC and OAuth | Stable lowercase ASCII key, independent of display name and authentication scheme. Keys must be unique across configured providers. | | `SubjectClaimType` | OIDC and OAuth | Exact claim type on the resulting authenticated `ClaimsPrincipal` that supplies the subject. Exactly one nonempty value is required, and the claim type must be outside the entire case-insensitive `urn:cratis:identity:*` namespace reserved for AuthProxy-authored metadata. There is no fallback to `sub`, name, username, or email. For OAuth user-info fields, map the raw JSON field to this principal claim with `ClaimMappings`. | | `EmailClaimType` | OIDC and OAuth | Exact provider-derived claim used for a signed invitation attestation. Defaults to `email`; exactly one address-shaped value is required during invitation completion. | | `EmailVerifiedClaimType` | OIDC and OAuth | Exact provider-derived boolean claim proving ownership of `EmailClaimType`. Defaults to `email_verified`; only one value equal to `true` is accepted. | | `AssuranceClaimType` | OIDC and OAuth | Exact provider-derived assurance claim. Defaults to `acr`; it must be present exactly once for invitation completion. | | `Issuer` | OAuth only | Explicit absolute HTTPS issuer assigned to the authenticated user-info flow. OIDC providers must omit it because AuthProxy uses the issuer from the framework-validated OIDC token. | An identity-bound invitation carries both `recipient_provider_key` and `recipient_identity_binding` in the signed invitation capability. AuthProxy accepts only one exact 43-character base64url SHA-256 binding, restricts provider selection to the exact canonical provider key, and completes only through a provider explicitly opted into `InvitationIdentityBindingCompletionEnabled`. AuthProxy treats the binding as opaque: the invitation authority must independently recompute and compare it from the attested provider key, validated issuer, and immutable subject. For Microsoft Entra, use a tenant-specific authority and `SubjectClaimType=oid`; never substitute email, `preferred_username`, or the mutable display name for the object identifier. For OAuth, `SubjectClaimType` names the claim after the configured user-info claim actions have run, not the raw JSON property returned by the provider. This complete example maps the raw user-info `id` field to a principal `sub` claim and then selects that `sub` claim as the canonical subject: ```json { "Cratis": { "AuthProxy": { "Authentication": { "OAuthProviders": [ { "Name": "GitHub Enterprise", "Type": "GitHub", "AuthorizationEndpoint": "https://github.example.com/login/oauth/authorize", "TokenEndpoint": "https://github.example.com/login/oauth/access_token", "UserInformationEndpoint": "https://github.example.com/api/user", "VerifiedEmailEndpoint": "https://github.example.com/api/user/emails", "ClientId": "", "ClientSecret": "", "Scopes": ["read:user", "user:email"], "ClaimMappings": { "sub": "id" }, "CanonicalIdentity": { "ProviderKey": "github-workforce", "SubjectClaimType": "sub", "Issuer": "https://github.example.com" } } ] } } } } ``` Given a user-info response such as `{ "id": 12345, "login": "octocat" }`, the mapping produces the principal claim `sub=12345`; canonical resolution reads that resulting `sub` claim. Without the mapping, setting `SubjectClaimType` to `sub` fails closed because no such principal claim exists. For OAuth providers such as GitHub whose ordinary user-information response can omit private addresses, configure `VerifiedEmailEndpoint`. AuthProxy calls it with the provider access token and accepts exactly one JSON-array entry whose `primary` and `verified` properties are both `true` and whose `email` property is nonempty. It replaces any ordinary user-information email with that verified value and writes the configured email-verification claim as `true`. A malformed, ambiguous, missing, or non-success response establishes no invitation email evidence. Request the provider scope needed by that endpoint (`user:email` for GitHub). When a provider supplies no configured assurance claim, AuthProxy records the successfully completed protocol as `oidc` or `oauth` in `AssuranceClaimType`. A provider-supplied value such as OIDC `acr` takes precedence. These values describe authentication assurance only; they do not grant application roles or membership. AuthProxy normalizes issuer scheme and host casing, removes a default port and trailing slash, and rejects userinfo, query strings, and fragments. Plain HTTP is accepted only for a loopback development issuer. The stable account key is the complete `(providerKey, normalizedIssuer, subject)` tuple. A raw subject is not globally unique: two providers can issue the same value, and some providers issue pairwise or client-specific subjects that change when the client registration changes. For Microsoft Entra workforce accounts, configure `SubjectClaimType` as `oid` when the tenant object ID is the intended account identifier. Do not use `preferred_username`, `upn`, or email as a subject. Those values are mutable and can be reassigned. For a provider where `sub` is the intended client-specific identifier, configure `sub` explicitly. After a fresh provider callback, AuthProxy removes case-insensitive collisions with its reserved claims and adds exactly one AuthProxy-authored set: * `urn:cratis:identity:provider-key` * `urn:cratis:identity:issuer` * `urn:cratis:identity:subject` The canonical subject also becomes `ClientPrincipal.UserId` and therefore the `x-ms-client-principal-id` value. Provider and issuer travel in the base64 client principal’s claim list; downstream Arc applications receive those claims even though Arc does not preserve the client principal’s `identityProvider` JSON property. The same tuple is posted by invitation exchange, credential linking, and sign-in notification. `x-ms-client-principal-id` is therefore the provider’s raw subject value, not a globally unique account key. Do not key cross-provider data or authorization by that header alone. Consumers that need the stable account identity must use all three reserved canonical claims as the `(providerKey, normalizedIssuer, subject)` tuple. #### Canonical session continuity A successful canonical browser sign-in binds the authentication cookie to the static provider registration that issued it. AuthProxy writes an opaque, versioned registration fingerprint into the protected authentication ticket for a normal sign-in callback. A credential-link callback does not create or replace a session, so it does not write this fingerprint. AuthProxy recalculates the fingerprint whenever it validates the cookie. The user must authenticate again if any of these registration inputs changed: * Provider protocol (`OIDC` or `OAuth`) or derived authentication scheme * Canonical `ProviderKey` or `SubjectClaimType` * Configured client ID or the effective client ID on the named authentication handler * For OIDC, `Authority` or the effective `MetadataAddress` * For OAuth, the normalized configured canonical `Issuer`; both the configured provider value and the effective named-handler value for `AuthorizationEndpoint`, `TokenEndpoint`, and `UserInformationEndpoint`; every configured `ClaimMappings` key/value pair; and both configured and effective authorization parameters. Collection order does not affect the fingerprint. The fingerprint contains no subject, email, claims, tokens, client secret, or other PII or secret input. It is an internal continuity marker, not an account identifier and not a claim forwarded to downstream services. OIDC issuers are different from static registration. AuthProxy records the issuer from the framework-validated token in each canonical session, but does not include that runtime issuer in the registration fingerprint. An unchanged multi-tenant OIDC registration can therefore accept sessions whose validated issuers differ by tenant. For OAuth, there is no validated ID-token issuer: the explicit canonical `Issuer` is static configuration, is checked again during cookie validation, and changing it forces reauthentication. The backward-compatibility carve-out applies only to a true legacy cookie: it has neither a claim in the case-insensitive `urn:cratis:identity:*` namespace nor a canonical registration fingerprint, and its recorded authentication scheme does not currently resolve to a canonical provider. AuthProxy accepts that cookie without applying canonical continuity. If a marker-free cookie’s recorded scheme now resolves to a canonical provider, AuthProxy rejects it and signs out the cookie session so the user establishes the canonical tuple through a fresh provider callback. A cookie that carries either canonical marker is likewise rejected and signed out when its canonical tuple or fingerprint is missing, malformed, or mismatched. Canonical identity proves provider authentication metadata only. Applications still decide membership, roles, scopes, and authorization. AuthProxy identity headers are authenticated by deployment topology, not by a per-request signature: prevent clients from reaching downstream services directly. Before forwarding any request, AuthProxy removes all inbound `x-ms-client-principal`, `x-ms-client-principal-id`, and `x-ms-client-principal-name` values. It writes exact trusted replacements only when it has an authenticated principal, so an unauthenticated caller cannot smuggle identity headers downstream. *** ## OAuth 2.0 providers Not every provider publishes an OpenID Connect discovery document. GitHub is the common one that does not, so it is configured under `Cratis:AuthProxy:Authentication:OAuthProviders` with its endpoints named explicitly instead of discovered from an authority: ```json { "Cratis": { "AuthProxy": { "Authentication": { "OAuthProviders": [ { "Name": "GitHub", "Type": "GitHub", "AuthorizationEndpoint": "https://github.com/login/oauth/authorize", "TokenEndpoint": "https://github.com/login/oauth/access_token", "UserInformationEndpoint": "https://api.github.com/user", "ClientId": "", "ClientSecret": "", "Scopes": [ "read:user", "user:email" ], "ClaimMappings": { "sub": "id", "name": "name", "preferred_username": "login", "email": "email" }, "AuthorizationParameters": { "prompt": "select_account" } } ] } } } } ``` OAuth providers sit alongside OIDC providers in every other respect: they appear on the provider-selection page, they get a login endpoint at `/.cratis/login/{scheme}`, and the count of both together decides whether an unauthenticated browser is challenged directly or offered a choice. `ClaimMappings` is what turns the provider’s user-info JSON into claims — the key is the claim type to create, the value is the field to read it from. The mapping above produces the claims AuthProxy reads when it builds the forwarded principal. `AuthorizationParameters` adds provider-specific, static values to each authorization request. The example asks GitHub to show account selection instead of silently reusing the active provider session. Keep request policy here rather than appending it to `AuthorizationEndpoint`, so the endpoint remains the provider endpoint. AuthProxy rejects keys owned by the OAuth handler, case-insensitively: `client_id`, `scope`, `response_type`, `redirect_uri`, `state`, `code_challenge`, and `code_challenge_method`. This preserves the framework-generated callback, correlation state, and PKCE values. Note OAuth 2.0 has no standard end-session endpoint, so signing out of an OAuth-established session clears the local session only — the user stays signed in at the provider. See [Logout](/authproxy/configuration/logout/). ### OAuthProviderConfig properties | Property | Type | Description | | --- | --- | --- | | `Name` | `string` | Display name shown on the login selection page, and the source of the scheme name. | | `Type` | `string` | Provider brand (`GitHub`, `Microsoft`, `Google`, `Apple`, or `Custom`). Picks the logo, and for GitHub also enables [organization and team claims](/authproxy/configuration/authorization/#github-organizations-and-teams). | | `AuthorizationEndpoint` | `string` | The OAuth 2.0 authorization endpoint URL. | | `TokenEndpoint` | `string` | The OAuth 2.0 token endpoint URL. | | `UserInformationEndpoint` | `string` | The user-information (profile) API endpoint URL. | | `ClientId` | `string` | OAuth 2.0 client ID. | | `ClientSecret` | `string` | OAuth 2.0 client secret. | | `Scopes` | `string[]` | Scopes to request. Adding `read:org` to a GitHub provider also adds organization and team claims to the session. | | `ClaimMappings` | `object` | Claim type → user-info JSON field name. | | `AuthorizationParameters` | `object` | Static authorization-request parameter → value. Framework-owned OAuth parameters are rejected at startup. | Configuring a provider decides *who can sign in*, which on a public provider is everybody. To decide who may then get through, see [Authorization](/authproxy/configuration/authorization/). *** ## Session lifetime and re-validation Interactive browser sessions are cookie-based, and every cookie AuthProxy issues for identity or tenant context is **session-scoped or short-lived** — closing the browser ends them. On top of that, `Cratis:AuthProxy:Session` bounds what a browser session that never closes may keep: ```json { "Cratis": { "AuthProxy": { "Session": { "Lifetime": "12:00:00", "SlidingExpiration": false, "CookieName": ".Cratis.AuthProxy.Auth.v2", "CookieDomain": "", "TerminateOnIdentityDenial": false, "IdentityRevalidationInterval": "00:10:00", "TenantRevalidationInterval": "00:10:00" } } } } ``` | Property | Default | Description | | --- | --- | --- | | `Lifetime` | `12:00:00` | Absolute lifetime of the authentication ticket. When it elapses the user must re-authenticate with the identity provider, even in a browser session that never closed. | | `SlidingExpiration` | `false` | Whether activity extends the ticket lifetime. Disabled by default so `Lifetime` is a hard bound. | | `CookieName` | `.Cratis.AuthProxy.Auth.v2` | Name of the session cookie. Set it to keep two AuthProxy deployments under one parent domain from reading each other’s sessions, or to retire an old cookie. Changing it signs users out once. It has to be a valid cookie name, and the `__Host-` prefix is refused when `CookieDomain` is set. | | `CookieDomain` | `""` (host-only) | Domain the session cookie is scoped to, for example `.example.com`, so one sign-in is honored on every host under it. Empty keeps today’s host-only cookie. See [One proxy, several hosts](/authproxy/configuration/one-proxy-several-hosts/). | | `TerminateOnIdentityDenial` | `false` | Whether an identity-verification denial ends the local AuthProxy session before serving the forbidden response. This signs out of AuthProxy and clears its session cookies; it does not log the caller out of the external identity provider. | | `IdentityRevalidationInterval` | `00:10:00` | How long a resolved authorization is remembered before the identity details — and the authorization they represent — are re-resolved against the services. Zero or negative means no bound: with any Required service (the default) no authorization record is written; when every participating service is BestEffort it falls back to ten minutes. The independent in-memory result cache still applies. | | `TenantRevalidationInterval` | `00:10:00` | How long a tenant selected through the [tenant-selection flow](/authproxy/configuration/tenant-selection/) is trusted before it is re-validated against `TenantsEndpoint`, so revoked tenant access takes effect without per-request backend calls. Zero or negative disables re-validation. | The authentication cookie itself carries no persistent `Expires` — the browser drops it when the session ends — and is `HttpOnly`, `SameSite=Lax`, and marked `Secure` whenever the site is served over HTTPS. It belongs to the one host that issued it unless you set `CookieDomain`. ### Sharing the session across hosts `CookieDomain` scopes the cookie to a parent domain so that one sign-in is honored on every host under it. Use the narrowest domain that covers the hosts in `Services::Hosts`: the cookie also goes to hosts that are not behind AuthProxy. AuthProxy refuses to start when a declared host is outside the domain, when the domain is an IP address, a top-level domain, a wildcard or carries a port, and when a tenant resolution uses the [`SubHost` strategy](/authproxy/configuration/tenancy/#subhost-strategy-options). It warns when `AllowedHosts` accepts every host. Browsers that still hold an earlier host-only cookie have it deleted on sign-in and sign-out. The full walk-through, including the single callback host `SignIn:CallbackHost`, is in [One proxy, several hosts](/authproxy/configuration/one-proxy-several-hosts/). Re-validation is cached in memory per instance, so within an interval no extra backend calls are made; when the interval lapses, a single backend round-trip refreshes the cached identity or tenant context. ### The two identity cookies The resolved identity is written to two cookies, and the split is a security boundary rather than an implementation detail: | Cookie | Readable by script | Contents | Role | | --- | --- | --- | --- | | `.cratis-identity` | Yes | Base64 JSON: the user id and name, the session’s roles (the provider’s roles plus `anonymous` and `authenticated`) and the `details` merged from the services’ `/.cratis/me` answers. Roles a service answers are not included | Lets a frontend render the signed-in user without a round-trip. **Never** treated by AuthProxy as evidence of anything. | | `.cratis-identity-authorization` | No (`HttpOnly`) | A sealed, unforgeable record | Carries the authorization decision that is allowed to skip the `/.cratis/me` call on later requests. | Because `.cratis-identity` is deliberately script-readable, anything a client can write must not decide authorization — so the decision lives in the sealed cookie instead. It is protected with ASP.NET data protection and bound to the user and tenant it was issued for, and its expiry is carried *inside* the sealed value rather than left to the cookie’s `Max-Age`, which a non-browser client is free to ignore. A record that cannot be unsealed (for example after a data-protection key rotation) is not a failure: the caller is simply re-authorized against the services. AuthProxy still reads unexpired version-one authorization records issued for legacy identities. That legacy format separated the expiry, raw subject, and tenant with `|`, so compatibility is limited to presented legacy subjects and tenant IDs that do not contain that delimiter. A delimiter-bearing value is rejected and the caller is re-authorized. Current version-two records use structured fields and do not inherit this legacy restriction. Deployments running more than one AuthProxy instance should configure a shared [Data Protection key store](/authproxy/configuration/data-protection/) so a record sealed by one instance can be read by the others; without it each instance re-resolves identity for callers whose record it did not issue. *** ## Forwarded identity headers Once a request is authenticated, AuthProxy tells the backend who is calling. It does that with identity headers and both tenant headers, written on proxied requests and on `/.cratis/me` calls — and it strips any inbound copy first, so a backend can treat them as proof rather than as a claim. What is stripped is wider than what is written. Every inbound header whose name begins `x-ms-client-principal` is removed whatever it ends in, so a header AuthProxy never writes but a backend might read — `x-ms-client-principal-idp`, say — cannot be used to smuggle a claim. The tenant is removed under both `x-cratis-tenant-id` and the legacy `Tenant-ID`, before and again after the proxy resolves its own, so the only tenant a backend can receive is the one AuthProxy resolved. A request with no resolved tenant reaches the backend with no tenant header at all. | Header | Carries | Encoding | | --- | --- | --- | | `x-ms-client-principal` | The full client principal as base64-encoded JSON | Always base64, so always US-ASCII | | `x-ms-client-principal-id` | The provider-local subject | Verbatim, or RFC 8187 — no sibling announces which | | `x-ms-client-principal-name` | The display name (`userDetails`) | Verbatim, or RFC 8187 when it cannot travel verbatim | | `x-ms-client-principal-name*` | The RFC 8187 form of the display name | Present **exactly** when the plain header carries an encoded value | | `x-cratis-tenant-id` | The resolved tenant | Verbatim, or RFC 8187 — no sibling announces which | | `Tenant-ID` | The same resolved tenant; forwarding deprecated until a future major release | Same as `x-cratis-tenant-id` | ### Every header value is US-ASCII A display name is whatever the identity provider says it is, and providers say things like `Søren Wærstad`, `Ольга Иванова`, `田中太郎`. An HTTP header field is octets, and .NET refuses to write a character above `U+007F` to one — it throws before a byte reaches the socket. So a name outside US-ASCII did not arrive garbled at the backend; the proxied request failed at the gateway and the identity-endpoint call failed silently, and the person could not use the application at all. AuthProxy now guarantees that every value it writes is printable US-ASCII. A value that already is one — and that could not be mistaken for an encoded one, see below — is sent **byte for byte unchanged**, so if your users’ names are ASCII nothing about the wire format changes and no extra header appears. Anything else is sent as an [RFC 8187](https://www.rfc-editor.org/rfc/rfc8187) `ext-value`: percent-encoded UTF-8 behind a self-describing `UTF-8''` prefix. ```http x-ms-client-principal-name: UTF-8''S%C3%B8ren%20W%C3%A6rstad x-ms-client-principal-name*: UTF-8''S%C3%B8ren%20W%C3%A6rstad ``` The starred sibling is the same idiom `Content-Disposition` uses to pair `filename` with `filename*` (RFC 6266 §4.3). Its **presence is the signal**: when `x-ms-client-principal-name*` is there, the plain header carries an encoded value; when it is absent, the plain header is the name verbatim. The plain header is always populated either way, so a backend that gates on the three headers being present keeps working untouched. Because the encoder emits only RFC 8187 `attr-char` octets, an encoded value can never contain `CR`, `LF` or `NUL`. That alone is not the whole guarantee, though, because a consumer that *decodes* can reconstruct whatever the encoding hid. A name a person chose to write as `UTF-8''victim%0D%0AX-Admin:%20true` is printable US-ASCII from end to end, so it would travel verbatim — and a backend that decided to decode because the value *looked* encoded would get a carriage return, a line feed and a header of the caller’s choosing back out of it. So the rule is not “encode what ASCII cannot carry”, it is **encode anything that could be mistaken for an encoded value**. A value beginning with `UTF-8''` is itself encoded, however ordinary the rest of it is, and the sibling header is emitted for it. Decoding it returns the literal name the person typed. The guarantee that follows is precise: > Decode exactly the values the sibling header announces, exactly once, and no identity value can put `CR`, `LF` or `NUL` into a header on your side. No realistic display name begins with `UTF-8''`, so nothing about ordinary traffic changes. Note The sibling header exists only for `x-ms-client-principal-name`. `x-ms-client-principal-id` and `x-cratis-tenant-id` go through the same encoder — a value outside US-ASCII on either is sent as an `ext-value` — but nothing announces it, so for those two headers the `UTF-8''` prefix is the only signal there is. That is deliberate: a subject and a tenant identifier are values your identity provider and your configuration issue, not values a person types, so treat them as opaque and forward them rather than decoding them. If you do decode one, decode it once and treat the result as data, never as a header. ### `x-ms-client-principal` is the canonical value The base64 principal is the source of truth for identity. Its `userDetails` property is the exact original name — every code point, no encoding, no normalization — because base64 was never subject to the ASCII limit in the first place. Read `userDetails` when you need the name itself. Read `x-ms-client-principal-name` when you want a convenient header and can either accept the encoded form or decode it. Cratis Arc already takes the first route: its Microsoft Identity Platform handler builds the user from the base64 principal, so an Arc backend behind AuthProxy sees exact Unicode names with no changes on its side. ```csharp // Decoding the header form yourself, when you are not on Arc. // Gate on the sibling header, never on the prefix: the sibling is what AuthProxy states, and the // prefix is only what the value looks like. A name a person typed as "UTF-8''..." looks encoded // and is not, and decoding it because it looked encoded is how a display name becomes a header. var name = request.Headers["x-ms-client-principal-name"].ToString(); if (request.Headers.ContainsKey("x-ms-client-principal-name*")) { name = Uri.UnescapeDataString(name["UTF-8''".Length..]); } ``` Decode once, and once only. The result is the person’s name, and a name is data — put it in a model, a log field or a response body, never back into a header. *** ## JWT Bearer (API) For machine-to-machine calls, configure a JWT Bearer handler: ```json { "Cratis": { "AuthProxy": { "Authentication": { "JwtBearer": { "Authority": "https://login.microsoftonline.com//v2.0", "Audience": "" } } } } } ``` The handler applies to every path, but a token from an issuer configured on a [bearer route](#bearer-routes-access-tokens-from-an-authorization-server) never reaches it: such a token is refused on every path outside its bearer routes. *** ## Bearer routes: access tokens from an authorization server A service can declare [bearer routes](/authproxy/configuration/services/#bearer-routes): path prefixes such as `/mcp` or `/v1` that are authenticated by an access token from an external authorization server — for example Cratis Identity — rather than by a browser session. AuthProxy remains an edge and relying party: it validates the token and forwards the request with the same trusted headers a browser session gets. ### Validation A token is accepted only when all of these hold: * It is a signed JWT (JWS); encrypted tokens and `alg: none` are refused. The algorithm is `RS256` or `ES256`. * Its `iss` is exactly one of the route’s issuers. * Its signature verifies against a key in the issuer’s JWKS. The metadata document (RFC 8414, or OpenID Connect discovery) must name exactly the configured issuer. Metadata and keys are cached and refreshed periodically, and a token naming an unknown key triggers a refresh at most every 30 seconds per issuer, so key rotation needs no restart. An allowed refresh completes before validation is retried in the same request. Known-key lookups use cached keys without waiting for retrieval; due automatic refreshes and their retries run in the background. A failed refresh keeps the last trusted keys in use and backs off retrieval for 30 seconds, including before the first success. Metadata naming another issuer is never trusted. When no trusted keys have been retrieved, tokens are refused with `503`, not as invalid. * Its `typ` header is an access-token type: the issuer’s `TokenTypes`, `at+jwt` or `application/at+jwt` by default. * Its `aud` names one of the route’s audiences. * It has an `exp`, and it is within its lifetime allowing the route’s clock skew (30 seconds by default). * It carries every scope the route requires, a single `sub`, and a single usable tenant. Then the deployment’s [claim requirements](/authproxy/configuration/authorization/) apply — the proxy-wide `Authorization` section and the route’s service’s own — to the principal after the route’s `ClaimMappings`, exactly as they apply to a browser session, together with the route’s own `RequiredClaims`. A route that sets `IgnoreDeploymentRequiredClaims` is held to its own requirements only, for a deployment whose requirements name a claim the issuer does not mint; AuthProxy logs a warning at startup for it. Role claims are dropped from the token first, so a requirement on a role can never be met by a bearer token. A mapping replaces every case variant of its target. Mapped `sub`, `preferred_username` and `name` must each have one usable source value; multiple values refuse the token. Mapping into the route’s tenant claim, or declaring targets differing only by case, is refused at startup. ### Responses | Situation | Response | | --- | --- | | Path still percent-encoded after decoding, or containing a backslash, repeated `/` separators, a `;` (path parameter, as in `/mcp/..;/api`) or a `.`/`..` segment, on any route when bearer routes are configured | `400` before route selection, no challenge | | No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | | Token invalid, expired, wrongly signed, from another issuer or for another audience; without a single `sub`; or without a claim a `ClaimMappings` entry reads | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | | Token does not satisfy the claim requirements that apply on the route | `403` | | Token carries no single usable tenant, or the tenant fails verification | `403` | | The issuer’s metadata or keys have never been retrieved, or the metadata names another issuer | `503` with `Retry-After: 30` | | Bearer-route token on any other path — whatever the case of the scheme or the whitespace after it, and in any of several `Authorization` headers | `401`, `WWW-Authenticate: Bearer error="invalid_token"` | `resource_metadata` is omitted when the route declares no `ResourceMetadataUrl`. Every refusal in the table carries `Cache-Control: no-store` and an empty body; the reason is logged, not returned. The path of `ResourceMetadataUrl` (for example `/.well-known/oauth-protected-resource/mcp`) is forwarded to the service backend for `GET` and `HEAD` without authentication and without any identity headers, `Cookie` or `Authorization`, so a client can discover the authorization server before it has a token. The backend serves the document. Any other method on that path is answered `405` with `Allow: GET, HEAD` and `Cache-Control: no-store`. ### Forwarded headers A request accepted on a bearer route is forwarded to the service backend with: | Header | Value | | --- | --- | | `x-ms-client-principal` | The principal: `identityProvider` from the route’s `IdentityProvider`, `userId` from `sub`, `userDetails` from `preferred_username`, else `name`, else `sub`, roles `anonymous` and `authenticated`, and the token’s claims — after the route’s `ClaimMappings` have applied. | | `x-ms-client-principal-id`, `x-ms-client-principal-name` | As for a browser session, from the same principal. | | `Tenant-ID` | The tenant from the token. | | `x-cratis-token-scope` | The granted scopes, space-separated. | | `x-cratis-token-client-id` | The client the token was issued to, from `azp` or else `client_id`. This is the client’s *asserted* identity: a public client cannot prove which program is using it. | The principal also carries claims AuthProxy writes itself: `urn:cratis:bearer:issuer`, `urn:cratis:bearer:subject` (the token’s own `sub`, kept when a mapping rewrites `sub`), `urn:cratis:bearer:client-id` and one `urn:cratis:bearer:scope` per scope. A token cannot supply any `urn:cratis:bearer:` or `urn:cratis:identity:` claim, and role claims in the token are not forwarded: a token never grants a role. Every inbound copy of these headers is removed on every route, bearer or not. The `Cookie` header is not forwarded on a bearer route, and neither is `Authorization` unless the route sets `ForwardAuthorizationHeader`. Every other request header is passed through as for any proxied request; AuthProxy adds no `Service-ID`. ### Identity verification is not applied A bearer route **never calls `/.cratis/me`**, whatever `IdentityVerification` says. That endpoint answers for browser sessions, and a product cannot be assumed to answer it correctly for a principal authenticated by a token. The consequences: * Under explicitly configured `IdentityVerification: BestEffort`, a service answering `403` on `/.cratis/me` refuses a browser session — but not a token on a bearer route. A user whose browser session a service refuses there can still reach the service with a token. **The backend must enforce tenant membership** and what the user may do with the scopes the client was granted. AuthProxy logs a warning at startup for every bearer route in a deployment where some service answers `/.cratis/me`, unless the route sets `AcceptWithoutIdentityVerification` to state that this is intended. * Under `IdentityVerification: Required` (the default), AuthProxy refuses to start with a bearer route unless the route sets `AcceptWithoutIdentityVerification`. * No identity details are resolved, so no `.cratis-identity` cookie is written. See [Bearer routes](/authproxy/configuration/services/#bearer-routes). *** ## Back-channel client credentials AuthProxy can also issue bearer tokens itself after a proxied service verifies the supplied client credentials over a private back channel. 1. The client sends `POST /.cratis/token` 2. The request body uses standard OAuth form fields: * `grant_type=client_credentials` * `service=` (optional when only one service has client credentials configured) * `client_id=` * `client_secret=` 3. AuthProxy calls the configured downstream verification endpoint with a JSON payload: ```json { "service": "portal", "routePrefix": "/api", "clientId": "orders-api", "clientSecret": "" } ``` 1. Any `2xx` response mints a bearer token scoped to that service and route prefix 2. Any `4xx` response rejects the credentials 3. Any `5xx` response is treated as a downstream verification failure Successful responses from `/.cratis/token` look like this: ```json { "access_token": "", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "" } ``` The issued bearer token can then be used on the configured route prefix (for example `/api/**`). AuthProxy validates that the token is used against the same configured service and route before forwarding the request. ### Resolving a tenant from the verification response The `2xx` response from the verification endpoint may optionally include a JSON body with a `tenant` property: ```json { "tenant": "acme" } ``` When present, AuthProxy embeds that value in the minted access token (and any refresh token issued alongside it) as a `cratis/tenant` claim. The claim travels with the token for its entire lifetime, so every subsequent request authenticated with that token carries it. To have AuthProxy resolve the tenant and set the `x-cratis-tenant-id` header on proxied requests, add a `Claim` [tenant resolution strategy](/authproxy/configuration/tenancy/#claim-strategy-options) pointing at that claim type: ```json { "Cratis": { "AuthProxy": { "TenantResolutions": [ { "Strategy": "Claim", "Options": { "ClaimType": "cratis/tenant" } } ] } } } ``` Like every other `Claim`-resolved value, the tenant returned by the verification endpoint is matched against the `SourceIdentifiers` configured for each entry in `Cratis:AuthProxy:Tenants` — it is not used directly as the Cratis tenant ID unless a tenant also lists it as one of its own source identifiers. See [Tenant registry](/authproxy/configuration/tenancy/#tenant-registry) for how that mapping works. ### Refreshing a token A client can exchange a refresh token for a new access token without resupplying its client credentials: 1. The client sends `POST /.cratis/token` 2. The request body uses: * `grant_type=refresh_token` * `refresh_token=` 3. AuthProxy validates the refresh token and, if it is still valid, mints a new access token and a new refresh token for the same service, client, and tenant — the response shape is identical to the one shown above. Refresh tokens are valid for 30 days and are not re-verified against the downstream service on refresh — since the client secret is not resent, AuthProxy trusts the refresh token itself rather than calling back to the target service. There is no revocation list: a leaked refresh token remains usable until it naturally expires, so treat it as a credential and keep its exposure to the same standard as a client secret. An expired or unrecognized refresh token is rejected with `401 Unauthorized` and `error: "invalid_grant"`. Refresh tokens cannot be used as access tokens (and vice versa) — each is protected separately, so presenting one where the other is expected is always rejected. ### Data Protection keys and horizontal scaling The authentication cookie and AuthProxy-issued client-credentials access and refresh tokens are all encrypted using ASP.NET Core Data Protection. By default, keys are not shared across instances. Running more than one AuthProxy replica, or needing sessions and client-credentials tokens to survive a restart, requires a shared key store. The simplest is a persistent, shared volume that `Cratis:AuthProxy:DataProtectionKeysPath` points at; Azure Blob Storage and Redis are also supported, see [Data Protection keys](/authproxy/configuration/data-protection/): ```json { "Cratis": { "AuthProxy": { "DataProtectionKeysPath": "/mnt/dataprotection-keys" } } } ``` Without this, a client-credentials token minted by one replica will fail to validate on another, and all outstanding tokens and sessions are invalidated on every restart. # Authorization > Make AuthProxy the first gate by requiring claims, or GitHub organization and team membership, before an authenticated caller can reach any service. Authentication tells you *who* is knocking. On a public host that is not the same as deciding whether they may come in. Put AuthProxy on the internet with GitHub sign-in and the sign-in works — for everybody. Every GitHub account on earth is a real, verified identity, so every one of them completes the handshake and lands on your application. The application is now responsible for turning away people it has never heard of, on every endpoint, forever, and the first mistake is a stranger inside. `Cratis:AuthProxy:Authorization` makes the proxy the **first gate**: an authenticated caller who does not carry what you require never reaches a service at all. It is off unless you turn it on. Declare nothing and AuthProxy behaves exactly as it always has. *** ## Declaring a requirement A requirement names a claim, and optionally the values you accept: ```json { "Cratis": { "AuthProxy": { "Authorization": { "RequiredClaims": [ { "Claim": "urn:github:organization", "AnyOf": [ "Cratis" ] } ] } } } } ``` An authenticated caller carrying `urn:github:organization` with the value `Cratis` gets through. Anyone else — signed in or not, and no matter which provider signed them in — is answered with the [not-authorized page](#what-a-refused-caller-sees) and goes no further. Leave `AnyOf` out to require only that the claim is **present**: ```json { "Claim": "urn:example:entitlement" } ``` That is the right shape when the identity provider already emits the claim only for the people who should get in, and enumerating the acceptable values in the proxy would mean restating — and then maintaining — a decision the provider has already made. Values are compared **case-insensitively**, and surrounding whitespace is trimmed. Organization names, team slugs and role names are case-insensitive in the systems they come from, and `Cratis` versus `cratis` is not a distinction worth locking a deployment out over. *** ## All of the claims, any of the values Two axes, and they compose in opposite directions. Say them out loud before you write them: | | Means | Reads as | | --- | --- | --- | | Several entries in `RequiredClaims` | **All** must be satisfied | *and* | | Several values in one entry’s `AnyOf` | **Any one** satisfies it | *or* | So “in the `Cratis` organization **and** on the `planner` team” is two entries: ```json "RequiredClaims": [ { "Claim": "urn:github:organization", "AnyOf": [ "Cratis" ] }, { "Claim": "urn:github:team", "AnyOf": [ "Cratis/planner" ] } ] ``` …while “on **either** of these two teams” is one entry with two values: ```json "RequiredClaims": [ { "Claim": "urn:github:team", "AnyOf": [ "Cratis/planner", "Cratis/operations" ] } ] ``` The distinction matters because the wrong reading fails in the dangerous direction. If entries were an *or*, adding a team requirement next to an organization requirement would **widen** access — to anyone in the organization *or* on that team in any organization — which is the opposite of what you meant by adding it. Note A requirement whose `Claim` is blank refuses to start. It could never be satisfied, so applying it would refuse every caller, and dropping it would silently leave the gate open. AuthProxy fails at startup instead, naming the exact configuration key — which is the one moment somebody is watching. *** ## Narrowing one service The same section on a service applies to requests routed to that service, **in addition to** whatever the root requires: ```json { "Cratis": { "AuthProxy": { "Authorization": { "RequiredClaims": [ { "Claim": "urn:github:organization", "AnyOf": [ "Cratis" ] } ] }, "Services": { "portal": { "Frontend": { "BaseUrl": "http://portal-web:3000/" }, "Backend": { "BaseUrl": "http://portal:8080/" } }, "admin": { "Frontend": { "BaseUrl": "http://admin-web:3000/" }, "Authorization": { "RequiredClaims": [ { "Claim": "urn:github:team", "AnyOf": [ "Cratis/operations" ] } ] } } } } } } ``` Everyone reaching either service is in the `Cratis` organization; only the operations team reaches `admin`. A service can therefore **narrow** who reaches it, never widen it. The alternative — a service section replacing the root’s — would turn the root into a default rather than a floor, so a section written to add an extra check would quietly drop the organization check, and a service added later with no section at all would be the way in. Which service a request targets is worked out the same way the [route table](/authproxy/configuration/services/) works it out: the service whose [host or path prefix](/authproxy/configuration/services/#routing-by-host-or-path-prefix) the request matches, the service named by the `x-cratis-microservice` header (or the legacy `Service-ID`) or the `service` query parameter, or the single configured service when there is only one, in the precedence the route table uses. A request in a multi-service deployment that matches no service reaches no service route either. When such a request still names a configured service, that service’s requirements apply anyway. An unknown header does not hide a query-selected service’s requirements. Otherwise only the root requirements apply. *** ## GitHub organizations and teams This is the case the feature was built for, and it needs one extra thing. GitHub’s user endpoint — `https://api.github.com/user`, the one you configure as `UserInformationEndpoint` — returns a profile and **nothing about membership**. There is no organization field to map to a claim, so no amount of `ClaimMappings` produces one. Membership lives behind `/user/orgs` and `/user/teams`, and reading those requires the `read:org` scope. **Ask for `read:org` and AuthProxy fetches it for you**, once, while the sign-in completes, and adds it to the session as claims: | Claim | Value | Example | | --- | --- | --- | | `urn:github:organization` | The organization’s GitHub login | `Cratis` | | `urn:github:team` | `organization/team-slug` | `Cratis/planner` | One claim per organization and one per team. The team slug is the name in the team’s URL — `github.com/orgs/Cratis/teams/planner` is the slug `planner`. It is qualified by its organization because a slug is only unique within one: two organizations may both have a `developers` team, and an unqualified claim would let membership of either satisfy a requirement written for one. The scope **is** the opt-in. There is no second switch to forget, and no way for the two to disagree. Without `read:org` nothing extra is requested, no extra call is made, and sign-in is exactly what it was. Here is a complete configuration restricting a host to the `planner` team in the `Cratis` organization: ```json { "Cratis": { "AuthProxy": { "Authentication": { "OAuthProviders": [ { "Name": "GitHub", "Type": "GitHub", "AuthorizationEndpoint": "https://github.com/login/oauth/authorize", "TokenEndpoint": "https://github.com/login/oauth/access_token", "UserInformationEndpoint": "https://api.github.com/user", "ClientId": "", "ClientSecret": "", "Scopes": [ "read:user", "user:email", "read:org" ], "ClaimMappings": { "sub": "id", "name": "name", "preferred_username": "login", "email": "email" } } ] }, "Authorization": { "RequiredClaims": [ { "Claim": "urn:github:organization", "AnyOf": [ "Cratis" ] }, { "Claim": "urn:github:team", "AnyOf": [ "Cratis/planner" ] } ] } } } } ``` A few things worth knowing before you deploy it: * **The claims are added at sign-in, not per request.** They live in the authentication cookie, so membership revoked at GitHub takes effect when the session ends — bound by `Cratis:AuthProxy:Session:Lifetime`, twelve hours by default. Shorten it if you need revocation to bite sooner. * **The application gets them too.** They travel on the forwarded `x-ms-client-principal` header like every other claim, so your service can read organization and team membership without ever calling GitHub. * **A failed read never breaks the sign-in.** If GitHub is unreachable, or the token lacks the scope, the claims are simply absent — which the gate then refuses, with an explanation. That is the closed direction, reached gently. * **Membership is read in pages of 100, up to five pages.** Well past any plausible allow-list, and the bound keeps a sign-in someone is waiting on from turning into an unbounded crawl. * **`Type` must be `GitHub`.** It is what selects the logo on the provider-selection page, and what tells AuthProxy these endpoints are GitHub’s. GitHub Enterprise works without further configuration: the membership endpoints are derived from whatever `UserInformationEndpoint` you configured, so `https://github.example.com/api/v3/user` resolves its own `/user/orgs` and `/user/teams`. *** ## What a refused caller sees On a [bearer route](/authproxy/configuration/services/#bearer-routes) the requirements apply to the access token’s principal, after the route’s claim mappings, and a refusal is a bare `403` with no page — the caller is a program, not a person. A route can declare requirements of its own, and can leave the deployment’s out with `IgnoreDeploymentRequiredClaims` when they name a claim the token issuer does not mint — see [BearerRouteConfig properties](/authproxy/configuration/services/#bearerrouteconfig-properties). For a browser session: The [`not-authorized.html`](/authproxy/configuration/well-known-pages/) page, at `403`, with a **Sign out** link. Both halves are deliberate. A redirect back to the identity provider — the reflex for “not allowed” — is wrong here: the caller *is* signed in, so they would sign in again as the same person and loop forever. And `403` is a status a `fetch()` or a command-line client can act on, so browsers and non-browsers get the same honest answer. The sign-out link matters because it is the only way forward. Someone who signed in with a personal account by mistake is otherwise stuck looking at a page that will not change. Override the page — brand it, tell people who to ask for access — by mounting a pages directory; see [Well-Known Pages](/authproxy/configuration/well-known-pages/). Every refusal is logged at warning level with the claim that was not satisfied and the path that was requested. The claim **type** only — never the value the caller carried, which would put an identity in the log to record that it was turned away. *** ## What is never gated Three things pass through untouched, and each for a reason worth knowing: * **Callers with no session.** They are refused — or sent to sign in — by the machinery that already does that. This gate is about who a signed-in caller *is*, not whether there is one. * **The authentication endpoints themselves** — `/.cratis/providers`, `/.cratis/login/{scheme}`, the provider callbacks, `/.cratis/logout` and the `/_pages` assets. Gating the only route to acquiring the claims on already having them is a door locked from the inside. * **Paths a service declares in [`AnonymousPaths`](/authproxy/configuration/public-surfaces/).** This is the one that surprises people, so be clear about it: a declared path exists precisely for callers with **no session** — a webhook receiver, a magic-link landing page — and a caller with no session carries no claims to satisfy anything with. Gating them would refuse every one of them, and a payment provider posting a webhook would get a `403` it can do nothing about. A declared anonymous path is public; requiring a claim somewhere else does not quietly un-declare it. Note That last point cuts both ways. If a path is declared anonymous, this gate will not protect it — so declare the narrowest prefix that works, and let the application authorize what it serves there. *** ## Environment variables Everything above is settable purely through environment variables, which is how AuthProxy is normally deployed. Array entries are indexed: ```bash Cratis__AuthProxy__Authorization__RequiredClaims__0__Claim=urn:github:organization Cratis__AuthProxy__Authorization__RequiredClaims__0__AnyOf__0=Cratis Cratis__AuthProxy__Authorization__RequiredClaims__1__Claim=urn:github:team Cratis__AuthProxy__Authorization__RequiredClaims__1__AnyOf__0=Cratis/planner ``` Per service, the same list moves under the service key: ```bash Cratis__AuthProxy__Services__admin__Authorization__RequiredClaims__0__Claim=urn:github:team Cratis__AuthProxy__Services__admin__Authorization__RequiredClaims__0__AnyOf__0=Cratis/operations ``` Indices must start at `0` and not skip — a gap truncates the list where the gap is, silently dropping every requirement after it. From a .NET Aspire app host, the same thing is expressed as builder calls: ```csharp builder.AddAuthProxy("authproxy") .WithRequiredClaim("urn:github:organization", "Cratis") .WithRequiredClaim("urn:github:team", "Cratis/planner") .WithRequiredClaimForService("admin", "urn:github:team", "Cratis/operations"); ``` Repeated calls append, so requirements can be declared wherever they belong in the app host rather than all in one place. *** ## Settings reference | Property | Type | Description | | --- | --- | --- | | `Authorization.RequiredClaims` | `ClaimRequirement[]` | Requirements every authenticated caller must satisfy. **All** of them must hold. Empty (the default) authorizes nobody away — the proxy authenticates without authorizing. | | `Authorization.RequiredClaims[].Claim` | `string` | The claim type the caller must carry. Blank fails startup. | | `Authorization.RequiredClaims[].AnyOf` | `string[]` | Values that satisfy the requirement, compared case-insensitively. Empty requires only that the claim is present. | | `Services..Authorization` | `Authorization` | The same shape, applied to requests routed to that service **in addition to** the root’s. | *** ## Checking it works Sign in with an account that qualifies and one that does not. The second should land on the not-authorized page at `403`, and nothing should reach your backend — no request, and no `/.cratis/me` identity call either, because the refusal happens before both. If a qualifying account is also refused, the requirement and the claim disagree. Read the warning in the proxy’s log: it names the claim that was not satisfied. For GitHub, the usual causes are the `read:org` scope missing from `Scopes`, a team named by its display name rather than its URL slug, or a team written without its `organization/` prefix. If *nobody* is refused, no requirement bound at all. Check the indices in the environment variables — a skipped index truncates the list. Next: [Public Application Surfaces](/authproxy/configuration/public-surfaces/) for the paths this gate deliberately leaves open, and [Authentication](/authproxy/configuration/authentication/) for the providers it gates. # Data Protection Keys > Configure the ASP.NET Core Data Protection key ring that AuthProxy uses to encrypt cookies and tokens, shared and persistent across replicas and restarts. AuthProxy encrypts its authentication cookie, the cookies it uses during sign-in, and every token it issues (including [client-credentials tokens](/authproxy/configuration/authentication/#back-channel-client-credentials)) with ASP.NET Core **Data Protection**. The keys that do the encrypting form a **key ring**. Every AuthProxy instance has to read what any other instance wrote, and every restart has to keep reading what the previous run wrote. Without a shared, persistent key ring neither holds: * With more than one replica, a request that lands on a replica other than the one that signed the caller in cannot read the session, so callers are signed out at random, in proportion to load. * After a restart, or on a platform whose disk is not persistent, every session and token is invalidated. So any deployment with more than one replica, or on a platform without persistent disks, needs a shared key store. AuthProxy supports three. | Store | Use it when | Setting | | --- | --- | --- | | `FileSystem` (default) | One instance, or replicas sharing a persistent volume. | `DataProtectionKeysPath` | | `AzureBlob` | You run on Azure and want no volume to manage. | `DataProtection:AzureBlob` | | `Redis` | You already run Redis, or run outside Azure. | `DataProtection:Redis` | Any of them can be combined with [Azure Key Vault key protection](#protecting-the-key-ring-with-azure-key-vault). *** ## File system (default) This is the behavior of every earlier release and needs no new setting. Point `DataProtectionKeysPath` at a directory on a persistent volume mounted at the same path for every replica: ```json { "Cratis": { "AuthProxy": { "DataProtectionKeysPath": "/mnt/dataprotection-keys" } } } ``` With no path, the framework’s per-machine default is used, which is neither guaranteed to survive a restart nor shared between replicas. Keys on a file system are stored unencrypted unless the platform encrypts the volume — add [Key Vault key protection](#protecting-the-key-ring-with-azure-key-vault) if that matters. *** ## Azure Blob Storage The key ring is stored as one blob. AuthProxy authenticates to it with [`DefaultAzureCredential`](https://learn.microsoft.com/dotnet/azure/sdk/authentication/credential-chains#defaultazurecredential-overview), so in Azure there is **no secret to configure**: a managed identity or workload identity is picked up from the environment. ```json { "Cratis": { "AuthProxy": { "DataProtection": { "Store": "AzureBlob", "AzureBlob": { "BlobUri": "https://myaccount.blob.core.windows.net/dataprotection/authproxy-keys.xml" } } } } } ``` | Setting | Meaning | | --- | --- | | `DataProtection:Store` | Must be `AzureBlob`. | | `DataProtection:AzureBlob:BlobUri` | The absolute `https` URI of the blob. **Required.** | | `DataProtection:ManagedIdentityClientId` | The client ID of a user-assigned managed identity. Leave unset for the system-assigned identity. | What the deployment has to provide: * The **container must already exist**. The blob is created on first use. * The identity AuthProxy runs as needs read and write access to the blob — the `Storage Blob Data Contributor` role on the container is enough. * With a **user-assigned** managed identity, set `ManagedIdentityClientId` to its client ID. Without it, `DefaultAzureCredential` looks for the system-assigned identity and fails if there is none. * The address must be `https`; a bearer token is never sent over plain `http`. As environment variables: ```bash Cratis__AuthProxy__DataProtection__Store=AzureBlob Cratis__AuthProxy__DataProtection__AzureBlob__BlobUri=https://myaccount.blob.core.windows.net/dataprotection/authproxy-keys.xml ``` *** ## Redis The key ring is stored under one Redis key. ```json { "Cratis": { "AuthProxy": { "DataProtection": { "Store": "Redis", "Redis": { "ConnectionString": "my-cache.redis.cache.windows.net:6380,password=...,ssl=true", "Key": "Cratis.AuthProxy:DataProtection-Keys" } } } } } ``` | Setting | Meaning | | --- | --- | | `DataProtection:Store` | Must be `Redis`. | | `DataProtection:Redis:ConnectionString` | A [StackExchange.Redis connection string](https://stackexchange.github.io/StackExchange.Redis/Configuration). **Required.** | | `DataProtection:Redis:Key` | The Redis key holding the key ring. Defaults to `Cratis.AuthProxy:DataProtection-Keys`. Give each deployment sharing one Redis its own value, or they share a key ring. | The connection string normally carries a password, so supply it from a secret store or an environment variable (`Cratis__AuthProxy__DataProtection__Redis__ConnectionString`) rather than a file in source control. What the deployment has to provide: * **Persistence.** The key ring lives only in Redis. If Redis loses it — a restart without persistence, an eviction — AuthProxy creates a new one and every session and token issued under the old keys stops working. Use a Redis with persistence enabled and a no-eviction policy for the database holding this key. * AuthProxy connects with `abortConnect=false` whatever the connection string says, so a Redis that is down while AuthProxy starts does not crash it: the connection keeps retrying in the background, the key ring fails to load until Redis is reachable, and the [readiness check](/authproxy/configuration/management-listener/) reports the replica as not ready until it is. Tune `connectTimeout` and `syncTimeout` in the connection string to decide how long a failing call may take. *** ## Protecting the key ring with Azure Key Vault Setting a Key Vault key encrypts **newly generated Data Protection keys** before they are written to **whichever store you chose**, so whoever can read the file, blob or Redis key cannot read those encrypted keys. Existing plaintext keys are not retroactively encrypted. ```json { "Cratis": { "AuthProxy": { "DataProtection": { "KeyVault": { "KeyIdentifier": "https://myvault.vault.azure.net/keys/authproxy-dataprotection" } } } } } ``` | Setting | Meaning | | --- | --- | | `DataProtection:KeyVault:KeyIdentifier` | The absolute `https` identifier of the key, for example `https://myvault.vault.azure.net/keys/authproxy-dataprotection`. **Required** when the section is present. | What the deployment has to provide: * An **RSA** key in the vault. AuthProxy wraps and unwraps the key ring with it (RSA-OAEP). * The identity AuthProxy runs as needs permission to get the key and wrap and unwrap with it — the `Key Vault Crypto User` role, or an access policy granting `get`, `wrapKey` and `unwrapKey`. The `get` permission resolves a versionless identifier to a specific key version so encrypted keys remain decryptable after vault-key rotation. It authenticates the same way as the blob store, including `ManagedIdentityClientId`. Note Enabling Key Vault protection does not replace the existing key ring or sign callers out. Existing plaintext keys remain readable and usable; only newly generated keys are encrypted. Changing the vault key identifier also leaves the ring in place: existing encrypted keys still depend on their original vault keys and versions. Keep those keys available and grant the identity access to them for as long as their Data Protection keys are needed. Do not remove the Key Vault protection setting while retaining encrypted keys: without its decryptor registration, those keys cannot be read. If you require a fresh encrypted ring and invalidation of existing sessions and tokens, deliberately configure a new, empty repository (a different directory, blob or Redis key) on every replica with Key Vault protection enabled. Do not copy the old plaintext keys into it. *** ## Choosing the store explicitly `Store` defaults to `FileSystem`, so existing configuration is unaffected. A contradiction is **refused at startup**, naming the setting, rather than resolved by picking one: | Configuration | Why it is refused | | --- | --- | | `Store` is not `FileSystem`, `AzureBlob` or `Redis` (including an undefined numeric value) | The chosen store is not supported. | | `Store` is `AzureBlob` without `AzureBlob:BlobUri`, or `Redis` without `Redis:ConnectionString` | The chosen store has nowhere to write. | | `DataProtectionKeysPath` set while `Store` is `AzureBlob` or `Redis` | The path would be ignored and the key ring written somewhere else. | | An `AzureBlob` or `Redis` section present while `Store` is anything other than that store (including the default `FileSystem`) | The section would be ignored. | | A blob URI or Key Vault identifier that is not an absolute `https` URI | It could never authenticate. | *** ## Multi-replica checklist 1. Pick one store and configure it identically on **every** replica. 2. Give the identity every replica runs as access to that store (and to the vault key, if used). 3. Enable the [management listener](/authproxy/configuration/management-listener/) and use `/health/ready` as the readiness probe. It performs a `Protect`/`Unprotect` round-trip through the configured key ring on every call, so a replica that cannot load the key ring from the store is kept out of rotation instead of failing sign-ins. Data Protection keeps the loaded key ring in memory and re-reads the store periodically, so an outage that begins after a replica is running does not sign its callers out; it surfaces when the ring is next refreshed or a new key has to be written. 4. Keep the application name `Cratis.AuthProxy`. AuthProxy sets it itself; it is part of what makes replicas share keys, and it cannot be changed through configuration. 5. Expect a new key ring, and a sign-out for everyone, when you move from one store to another. Keys are not copied between stores. # Custom Error Pages > Replace AuthProxy's built-in HTML error pages with your own by mounting a directory of custom pages and setting the PagesPath option. AuthProxy serves user-friendly HTML pages for error conditions instead of bare HTTP status codes. Every error page can be **overridden** by mounting a directory of custom pages into the container. *** ## Built-in pages The following pages are included with AuthProxy and are served automatically when the corresponding condition is detected: | File name | Condition | HTTP status | | --- | --- | --- | | `404.html` | The requested resource was not found. | 404 | | `403.html` | The identity resolver denied access. | 403 | | `tenant-not-found.html` | The resolved tenant does not exist in the platform (see [Tenant verification](/authproxy/configuration/tenancy/#tenant-verification)). | 404 | | `no-organization.html` | The caller is signed in and tenant resolutions are configured, but no tenant could be resolved and no lobby redirect applies (see [Tenancy](/authproxy/configuration/tenancy/) and [Tenant selection](/authproxy/configuration/tenant-selection/)). | 403 | | `select-tenant.html` | Tenant selection is enabled and the authenticated user has not selected a tenant yet. The page reads the `.cratis-tenants` cookie to render selectable tenants. | 200 | | `service-unavailable.html` | The tenant selection endpoint (`TenantsEndpoint`) could not be reached or did not return a usable answer, so the caller’s tenants could not be determined. | 503 | | `invitation-expired.html` | An invite link was followed but the JWT token has passed its expiry time. | 401 | | `invitation-invalid.html` | An invite link was followed but the JWT token is malformed or has an invalid signature. | 401 | | `invitation-select-provider.html` | A valid invite link was followed and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 | | `invitation-subject-already-exists.html` | The authenticated user’s subject is already associated with an existing account during invite exchange (Phase 2). | 409 | | `invitation-email-unavailable.html` | Email binding is enabled, but the identity provider supplied no authenticated-session email address during invite exchange (Phase 2). | 403 | | `invitation-email-mismatch.html` | Email binding is enabled, and the identity provider supplied another address or did not report the address as verified during invite exchange (Phase 2). | 403 | *** ## Overriding pages Mount a directory into the container and point `Cratis:AuthProxy:PagesPath` at the mount path. AuthProxy looks up each page by its conventional file name inside that directory. Any page file that is present overrides the built-in default; missing files fall back to the built-in version. ```json { "Cratis": { "AuthProxy": { "PagesPath": "/mnt/pages" } } } ``` Equivalent environment variable: ```dotenv Cratis__AuthProxy__PagesPath=/mnt/pages ``` ### Container mount example (Docker Compose) ```yaml services: authproxy: image: cratis/authproxy:latest volumes: - ./my-pages:/mnt/pages environment: Cratis__AuthProxy__PagesPath: /mnt/pages ``` ### Page assets Pages can reference stylesheets, images, and other assets.\ Place asset files in the same pages directory — they are served at the `/_pages/` URL prefix. ```html Logo ``` *** ## No organization page `no-organization.html` is served with a `403` when a user is signed in but is not associated with any organization: tenant resolutions are configured, none resolved a tenant, and no lobby redirect applies. It is a `403` rather than a `401` so a frontend does not take it for “not signed in” and loop back to the login flow. See [Tenancy](/authproxy/configuration/tenancy/) and [Tenant selection](/authproxy/configuration/tenant-selection/) for how a tenant is resolved. Override it like any other page: place a `no-organization.html` in the directory that `Cratis:AuthProxy:PagesPath` points at. The page is served to any caller, so a script that fetches a protected path such as `/.cratis/me` receives this HTML with a `403`; check the response status before parsing it. *** ## Invitation error pages Invitation error pages use **full descriptive names** rather than numeric HTTP status codes because they represent application-level conditions, not generic HTTP errors. ### `invitation-expired.html` Served when a user follows an `/invite/` link whose JWT has a valid signature but has passed its `exp` claim. The user should request a fresh invitation. ### `invitation-invalid.html` Served when the token on an `/invite/` link is malformed, carries an invalid signature, or cannot be parsed at all. This typically indicates a truncated or otherwise corrupted link. ### `invitation-select-provider.html` Served when a valid invite link is followed and **two or more** identity providers are configured. Before serving the page, AuthProxy injects the `.cratis-providers` cookie (see below) so the page can render a sign-in button for each available provider without an additional HTTP round-trip. The built-in page reads the cookie with JavaScript and renders one sign-in button per provider. You can override it with a custom branded version by placing your own `invitation-select-provider.html` in the configured pages directory. ### `invitation-subject-already-exists.html` Served during Phase 2 (post-login invite exchange) when the exchange endpoint returns HTTP 409 Conflict, indicating that the authenticated user’s subject is already associated with an existing account. The user should sign in with their existing account rather than completing the invitation again. If you prefer to redirect users to a custom URL instead of serving this page, configure `Invite.SubjectAlreadyExistsUrl` (see [Invitation for Creating Organization](/authproxy/configuration/lobby/invitation-for-creating-organization/#configuration)). ### `invitation-email-unavailable.html` Served during Phase 2 when `Invite.EmailClaim` binds the invitation to an address but the identity provider supplied no authenticated-session email address. This is not evidence that the invitee used the wrong account; it means AuthProxy has no address to compare. Customize this page to help the user choose a provider or account that exposes an address. ### `invitation-email-mismatch.html` Served during Phase 2 when the provider supplied an address different from the invited address, or did not report the address as verified. What counts as unverified depends on the mode. In the legacy unsigned exchange (no `Invite.Attestation`) only an explicit `email_verified=false` mismatches; an absent claim is forwarded as `null` for the backend to judge, and OAuth providers do not currently map that claim. With `Invite.Attestation` configured, the completion fails closed: only a single `email_verified` claim parsing to exactly `true` counts as verified, so a missing, duplicated, malformed, or non-`true` claim is answered with this page too. See [Invitation to Organization](/authproxy/configuration/lobby/invitation-to-organization/) for the complete binding behavior. ### Invitation email substitutions Replacement invitation pages can use these literal tokens in HTML text or quoted attributes: | Token | Available on | Value | | --- | --- | --- | | `{{invitedEmail}}` | `invitation-select-provider.html`, `invitation-email-mismatch.html`, `invitation-email-unavailable.html` | The validated invitation’s recipient address, or empty for an invitation without an email recipient. | | `{{assertedEmail}}` | `invitation-email-mismatch.html` | The provider’s single usable email address. Empty when no unambiguous address was supplied; never inferred from a username. On the other invitation pages it is empty. | For example, a replacement mismatch page can contain: ```html

Invited address: {{invitedEmail}}

Selected account: {{assertedEmail}}

``` Only addresses made of ASCII letters, digits, `.`, `_`, `+`, `-` and `@` are displayed; addresses containing any other character are replaced with an empty string. Values are still HTML-encoded and display-only: they do not change verified-email matching or authorize an invitation. They are supplied only for a validated invitation flow and its correlated callback, not for a bare static-page URL, invalid capability, or unrelated authentication failure. Use the tokens only in HTML text or quoted attributes, never in unquoted attributes, event-handler attributes, ` ``` > **Note:** Assets placed in the same pages directory are served at the `/_pages/` URL prefix. The `brand.css` reference above assumes `brand.css` lives alongside your custom page. The same HTML template works for both `select-provider.html` and `invitation-select-provider.html`. You can use identical files, or tailor each one — for example, showing “You have been invited” in the invitation variant and “Sign in to continue” in the direct-access variant. *** ## Deploying custom pages 1. Create the HTML file(s) named exactly `select-provider.html` and/or `invitation-select-provider.html`. 2. Place any CSS, images, or other assets in the same directory. 3. Mount the directory into your container and point `Cratis:AuthProxy:PagesPath` at the mount path: docker-compose.yml ```yaml services: authproxy: image: cratis/authproxy:latest volumes: - ./my-pages:/mnt/pages environment: Cratis__AuthProxy__PagesPath: /mnt/pages ``` AuthProxy resolves each page file by name — if a file exists in the configured `PagesPath` it is used; otherwise the built-in default is served. *** ## Using the `type` field for branded buttons The `type` value maps to the `OidcProviderType` enum. Use it to apply provider-specific logos or colors: ```javascript var LOGOS = { Microsoft: '/_pages/icons/microsoft.svg', Google: '/_pages/icons/google.svg', GitHub: '/_pages/icons/github.svg', Apple: '/_pages/icons/apple.svg', Custom: '/_pages/icons/generic.svg' }; providers.forEach(function (provider) { var a = document.createElement('a'); a.href = provider.loginUrl; var img = document.createElement('img'); img.src = LOGOS[provider.type] || LOGOS.Custom; img.alt = provider.name; img.width = 20; img.height = 20; a.appendChild(img); a.appendChild(document.createTextNode(' Sign in with ' + provider.name)); document.getElementById('providers').appendChild(a); }); ``` *** ## Complete flow reference ### Direct access flow (`select-provider.html`) ```text User navigates to protected resource │ ▼ Unauthenticated request detected │ ├─ Single provider ──► redirect to /.cratis/login/ │ └─ Multiple providers │ ▼ Set .cratis-providers cookie Serve select-provider.html │ │ User clicks provider ▼ GET /.cratis/login/ │ ▼ OIDC/OAuth challenge │ ▼ Redirect to original resource ``` ### Invitation flow (`invitation-select-provider.html`) ```text User clicks invite link │ ▼ GET /invite/ │ ├─ Token invalid/expired ──► invitation-invalid.html / invitation-expired.html │ └─ Token valid │ ├─ Single provider, not signed in ──► redirect to /.cratis/login/ │ └─ Multiple providers, or already signed in │ ▼ Set .cratis-providers cookie Serve invitation-select-provider.html │ │ User clicks provider ▼ GET /.cratis/login/ │ ▼ OIDC/OAuth challenge │ ▼ POST-login exchange │ ▼ Redirect to lobby / application ``` ### Callers who are already signed in An invitation is completed only with the identity that authenticated **for that invitation**. A session the browser was already carrying — from an earlier sign-in, possibly with a different provider — never completes one, even though it is a perfectly valid session: the organization would be bound permanently to an account the person did not choose for it. Such a caller is therefore always shown `invitation-select-provider.html`, even where only one provider is configured, and the invitation completes with whatever identity comes back from the provider they pick. Picking the provider they are already signed in with normally costs no more than a redirect, because the identity provider still has its own session. The pending invitation is left in place while this happens, so nothing is lost — it stays valid for the lifetime of the `.cratis-invite` cookie. # Credential Linking > Let a signed-in user prove control of another identity-provider login and link it to their account through AuthProxy without replacing their session. AuthProxy can let an **already signed-in** user prove control of an additional identity-provider login and associate it with their existing account — without ever replacing their current session. This is the proof-of-control building block behind an application’s “add a credential” feature. ```text GET /.cratis/link?token= → embeddable provider-selection page GET /.cratis/link/{scheme}?returnUrl=&token= → challenge for one provider GET /.cratis/link/complete → completion page ``` It is deliberately **not** the same as `/.cratis/login/{scheme}`: login signs the authenticated identity into the primary session cookie (which, for a second identity, would swap who the user is — effectively logging them out of the original account). The link flow authenticates the second provider, captures its subject, and hands that subject to the application, all while leaving the primary session untouched. *** ## Embedding the flow in the application The flow is designed to run inside a **modal iframe** on the application’s own page. There is one hard constraint it is built around: the provider leg cannot be framed. External identity providers (`login.microsoftonline.com`, `accounts.google.com`, …) send `X-Frame-Options: DENY` / `frame-ancestors 'none'` on their sign-in pages, so any challenge honored inside an iframe leaves a dead frame. The flow is therefore split: * **The iframe shows AuthProxy pages only.** The application embeds `/.cratis/link?token=…` — the provider-selection page. It lists the configured providers and, on a click, opens `/.cratis/link/{scheme}` with `window.open` — a separate **top-level** window where the provider authenticates. AuthProxy enforces this shape server-side: a navigation to `/.cratis/link/{scheme}` whose `Sec-Fetch-Dest` says it is framed is answered with the selection page instead of a challenge. * **The provider window reports back over a `BroadcastChannel`.** The completion page (`/.cratis/link/complete`, where the flow ends by default) and the failure page broadcast `{ type: 'cratis:credential-link-complete' }` / `{ type: 'cratis:credential-link-failed' }` on the same-origin channel `cratis.credential-link` — deliberately not `window.opener`, which an identity provider’s `Cross-Origin-Opener-Policy` can sever mid-flow. The completion window then closes itself. * **The framed page signals the parent with `postMessage`.** On either outcome the selection page forwards the message to `window.parent`, targeted at the configured embed ancestor origins (never `*`), so the application can close its modal and refresh — or leave the modal open for a retry after a failure. Embedding is **off by default**. The link pages send `Content-Security-Policy: frame-ancestors 'none'` (plus `X-Frame-Options: DENY`) until the deployment names the origins allowed to frame them: ```dotenv Cratis__AuthProxy__Link__EmbedAncestors__0=self ``` `self` means the proxy’s own origin — the common case, where the application is served through the proxy. Additional entries may name other origins (e.g. `https://app.example.com`). This setting opens the **link pages only**; sign-in and selection pages always refuse framing, and proxied application responses are never touched. Opening `/.cratis/link/{scheme}` directly in a popup or top-level redirect — the pre-embedding shape — still works exactly as before. *** ## How it works 1. **The application mints a one-time link token.** When the user starts “add a credential”, the application issues a short-lived, single-use token bound to that signed-in user, and opens the popup at `/.cratis/link/{scheme}?returnUrl=…&token=…`. The `scheme` is a configured provider scheme (the same value used by `/.cratis/login/{scheme}`, e.g. `github`). 2. **AuthProxy challenges the provider.** The request must come from an authenticated session (an anonymous request is rejected with `401`; an unknown scheme with `404`; a missing token with `400`). AuthProxy starts an OAuth/OIDC challenge for the requested scheme, carrying a link-mode marker and the link token through the authentication properties. 3. **On the provider callback the identity is captured, not signed in.** Instead of writing the primary authentication cookie, AuthProxy reads the freshly authenticated `subject` (and identity provider) and `POST`s them to the configured [`ExchangeUrl`](#configuration), authenticated with the link token as the bearer credential — exactly mirroring the [invite exchange](/authproxy/configuration/lobby/invitation-to-organization/). The user’s original session is preserved. 4. **AuthProxy ends the flow — completion only when the exchange succeeded.** On success the browser is redirected to the supplied `returnUrl`, or to the flow’s own completion page (`/.cratis/link/complete`) when none was supplied — which broadcasts the outcome and closes the window. If the exchange did not succeed — the endpoint is not configured, the link token or the provider subject could not be resolved, the endpoint was unreachable, or the application answered with a non-2xx status — the browser receives a generic link-failure page with HTTP `403` instead, and never the completion redirect. The page is the same for every cause, so it reveals nothing about which one occurred; the cause is logged for the operator. A failed provider round-trip (correlation failure, provider error, the person cancelling) ends on the same failure page — never on the sign-in selection page, whose full sign-ins would offer to replace the very session the link was preserving. In every case the user’s primary session is left exactly as it was. The request body posted to `ExchangeUrl` depends on whether the selected provider opts into [canonical federated identity](/authproxy/configuration/authentication/#canonical-federated-identity). Legacy providers keep the existing body: ```json { "subject": "", "identityProvider": "" } ``` Canonical providers add the stable provider-aware identity fields: ```json { "subject": "", "providerKey": "", "issuer": "", "identityProvider": "" } ``` with `Authorization: Bearer `. Canonical identity is opt-in per provider. During migration, the application endpoint must accept both bodies. For a canonical body, bind the account with the complete `(providerKey, issuer, subject)` tuple; never treat `subject` alone or the compatibility `identityProvider` field as the stable account key. The tuple records provider authentication metadata only. The application still decides whether that identity may link a credential to the current account. *** ## The `returnUrl` parameter `returnUrl` is echoed back to the browser after the link completes, so it is constrained to a **same-site relative path** (a single leading `/`, but not `//`). Anything else — including an absolute URL to another origin — falls back to the flow’s own completion page (`/.cratis/link/complete`), so the endpoint can never be turned into an open redirect. Omitting `returnUrl` lands on the completion page too, which is the right default for the embedded flow. *** ## Configuration Set the application endpoint that records the freshly authenticated subject under `Cratis:AuthProxy:Link:ExchangeUrl`. It is the link counterpart of `Cratis:AuthProxy:Invite:ExchangeUrl`. ```json { "Cratis": { "AuthProxy": { "Link": { "ExchangeUrl": "https://studio.example.com/api/internal/identity-providers/link", "EmbedAncestors": ["self"] } } } } ``` Equivalent environment variables: ```dotenv Cratis__AuthProxy__Link__ExchangeUrl=https://studio.example.com/api/internal/identity-providers/link Cratis__AuthProxy__Link__EmbedAncestors__0=self ``` `EmbedAncestors` names the origins allowed to embed the link pages in an iframe (see [Embedding the flow in the application](#embedding-the-flow-in-the-application)); when empty — the default — the link pages refuse framing entirely. When `ExchangeUrl` is empty or the `Link` section is absent, there is nowhere to post the subject, so the link callback cannot complete: the browser receives the generic link-failure page (HTTP `403`) rather than the completion redirect. The flow is effectively disabled, and it fails visibly rather than reporting success for a link that was never recorded. > **Security.** The JSON callback body is not signed. The one-time bearer token binds the operation to the signed-in application user, but it does not by itself prove that AuthProxy sent the HTTP request. Keep `ExchangeUrl` network-isolated from public traffic or authenticate AuthProxy separately at the application endpoint. Keep the token short-lived and single-use, and never turn successful provider authentication into application authorization without applying the application’s own linking policy. # Lobby > The AuthProxy lobby finishes onboarding for users who cannot enter the application directly, through invitations or self-serve registration. AuthProxy uses the lobby to finish onboarding when a user cannot enter the application directly after authentication. Use the lobby documentation based on the onboarding outcome you need: * [Invitation for Creating Organization](/authproxy/configuration/lobby/invitation-for-creating-organization/) — invite a user who should create a new organization after they sign in. * [Invitation to Organization](/authproxy/configuration/lobby/invitation-to-organization/) — invite a user directly into an existing organization. * [Registration](/authproxy/configuration/lobby/registration/) — let a user start a self-serve registration flow that ends in organization creation. If the user should join an existing organization, invite them instead. ## Shared invitation routing AuthProxy compares the validated invitation’s configured `TenantClaim` value with the tenant resolved for the request. That equality is observational routing evidence, not issuer identity: it proves only that both observed values are equal. It does not prove which tenant issued the invitation, because any authority holding the invitation signing key can write the claim. | Observed tenant relation | `MatchingTenantInvitationDestination` | Successful completion destination | | --- | --- | --- | | Matching | `ReturnUrl` (default) | The invitation challenge’s return URL. | | Matching | `Lobby` | `Lobby.Frontend.BaseUrl`, with the invitation ID query parameter when enabled. | | Nonmatching | Either value | `Lobby.Frontend.BaseUrl` when configured; otherwise the return URL. | | Unresolved | Either value | `Lobby.Frontend.BaseUrl` when configured; otherwise the return URL. | The destination setting changes only the matching row. It does not change validation, staging, exchange, recipient binding, attestations, transaction consumption, cookies, or sessions. ## Shared configuration All lobby-related settings live under `Cratis:AuthProxy:Invite:Lobby`: ```json { "Cratis": { "AuthProxy": { "Invite": { "Lobby": { "Frontend": { "BaseUrl": "http://lobby-service:3000/" }, "Backend": { "BaseUrl": "http://lobby-service:8080/" }, "Registration": { "BaseUrl": "http://lobby-service:3000/register" } } } } } } ``` The `Lobby` object uses the standard [`Service`](/authproxy/configuration/services/) shape. | Property | Type | Description | | --- | --- | --- | | `Frontend.BaseUrl` | `string` | URL used for lobby redirects after onboarding or when tenant resolution fails. | | `Backend.BaseUrl` | `string` | Optional backend API URL for the lobby service. | | `Registration.BaseUrl` | `string` | Optional URL used after `GET /register` completes successfully. | ## Lobby fallback Tenant resolution runs before invite and registration handling. When AuthProxy cannot resolve a tenant and a lobby frontend is configured, it redirects the user to `Lobby.Frontend.BaseUrl` unless the request is already an onboarding bootstrap path such as `/invite/` or `/register`. # Invitation for Creating Organization > Invite a user through AuthProxy so that, after signing in, they land in the Lobby frontend to create a new organization. Use this flow when you invite a user who should create a new organization after they authenticate. AuthProxy validates and stages the invite with the Lobby invitation authority, authenticates the recipient, completes the staged transaction with a signed attestation, and then sends the user to the Lobby frontend. ## Flow 1. The user opens `https://your-authproxy/invite/`. 2. AuthProxy validates the signed JWT invite token. 3. AuthProxy generates an independent transaction and challenge, signs an `invite-stage` attestation, and calls `Invite.StageUrl`. The Lobby invitation authority validates the exact capability, persists only its hash and bounded transaction state, and discards the raw token. 4. AuthProxy protects the staged state and invite token in short-lived HTTP-only cookies. * With one configured identity provider, AuthProxy challenges that provider immediately. * With multiple providers, AuthProxy serves `invitation-select-provider.html` so the user can choose how to sign in. 5. After successful provider authentication, AuthProxy re-validates the token and protected transaction bindings, derives canonical provider identity from the authenticated ticket, and calls `Invite.ExchangeUrl` with a signed `invite-complete` attestation. The JSON body contains only the opaque transaction ID. 6. The same Lobby invitation authority verifies and atomically consumes the transaction and attestation ID. If it succeeds, AuthProxy redirects the user to `Invite.Lobby.Frontend.BaseUrl`. This flow is the right fit when the invited user must create an organization. Configure `MatchingTenantInvitationDestination` as `Lobby` so the creating-organization journey still selects Lobby when the invitation tenant claim happens to match the tenant resolved for the request. ## Configuration ```json { "Cratis": { "AuthProxy": { "Invite": { "PublicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", "Issuer": "https://ada.example.com", "Audience": "authproxy", "StageUrl": "https://lobby.example.com/_invite/stage", "ExchangeUrl": "https://lobby.example.com/_invite/exchange", "TenantClaim": "tenant_id", "MatchingTenantInvitationDestination": "Lobby", "EmailClaim": "email", "Attestation": { "Issuer": "https://auth.example.com", "Audience": "ada-lobby", "ActiveKeyId": "invite-2026-08", "Lifetime": "00:01:00", "SigningKeys": [ { "KeyId": "invite-2026-08", "PrivateKeyPem": "" } ] }, "SubjectAlreadyExistsUrl": "https://app.example.com/errors/account-already-exists", "AppendInvitationIdToQueryString": true, "InvitationIdQueryStringKey": "invitationId", "ClaimsToForward": [ { "FromClaimType": "organization_id", "ToClaimType": "organization" }, { "FromClaimType": "invited_by" } ], "Lobby": { "Frontend": { "BaseUrl": "http://lobby-service:3000/" }, "Backend": { "BaseUrl": "http://lobby-service:8080/" } } } } } } ``` | Property | Type | Description | | --- | --- | --- | | `PublicKeyPem` | `string` | PEM-encoded RSA public key used to verify invite token signatures. | | `Issuer` | `string` | Expected `iss` claim. Leave empty to skip issuer validation. | | `Audience` | `string` | Expected `aud` claim. Leave empty to skip audience validation. | | `StageUrl` | `string` | Absolute URL of the Lobby invitation authority’s staging endpoint. | | `ExchangeUrl` | `string` | Absolute URL of the same Lobby invitation authority’s completion endpoint. | | `TenantClaim` | `string` | Claim containing the tenant observed for invitation routing. Required by the signed protocol. Equality with the resolved tenant does not identify the invitation issuer. | | `MatchingTenantInvitationDestination` | `InvitationCompletionDestination` | Set to `Lobby` so a matching tenant still enters the organization-creation journey. | | `EmailClaim` | `string` | Claim type used by the exclusive email-recipient mode. The signed capability must contain exactly one value of this claim or the immutable provider-binding pair, never both. | | `Attestation` | `object` | RS256 issuer, audience, active key, private signing-key set, and 10–60-second lifetime used for the two internal calls. | | `SubjectAlreadyExistsUrl` | `string` | Redirect target when the exchange endpoint returns HTTP 409. Leave empty to serve `invitation-subject-already-exists.html`. | | `AppendInvitationIdToQueryString` | `bool` | Appends `jti` from the invite token to the lobby redirect URL when enabled. | | `InvitationIdQueryStringKey` | `string` | Query-string key used when appending the invitation ID. | | `ClaimsToForward` | `InviteClaimForwarding[]` | Claim mappings forwarded from the invite token into the identity details request. | | `Lobby.Frontend.BaseUrl` | `string` | Lobby URL used after a successful invite exchange. | ## Signed protocol Creating-organization and existing-organization invitations use the same signed two-stage Lobby protocol. See [Signed protocol](/authproxy/configuration/lobby/invitation-to-organization/#signed-protocol) for the exact stage and completion bodies, attestation claims, exclusive recipient modes, verification rules, key rotation, and compatibility contract. The browser never authors provider identity. Both internal calls are authenticated by short-lived AuthProxy attestations, and the completion body contains no email, provider, issuer, or subject fields. > **Legacy compatibility.** Omitting `Invite.Attestation` retains the released unsigned JSON exchange. That mode is not sufficient authority for creating or linking an account and must not be used for production onboarding. ## Invite claim forwarding When `ClaimsToForward` is configured and a pending invite cookie exists, AuthProxy reads the configured invite-token claims and adds them to the principal payload sent to each `/.cratis/me` identity details endpoint. * Existing identity-provider claims are preserved. * Mapped invite claims are appended if present. * If `ToClaimType` is empty, AuthProxy uses `FromClaimType`. ## Invite token format Invite tokens are JWTs signed with an RSA private key held by the issuing service. AuthProxy only needs the matching public key to validate the signature. Recommended claims: | Claim | Description | | --- | --- | | `iss` | Issuer. Must match `Invite.Issuer` when configured. | | `aud` | Audience. Must match `Invite.Audience` when configured. | | `exp` | Expiry time. Expired tokens are rejected. | | `jti` | Unique invitation identifier. Required by the signed protocol. | | `tenant_id` | Tenant value observed for invitation routing, using the configured `TenantClaim` name. | | `email` | Exactly one invited address for email-recipient mode, using the configured `EmailClaim` name. | | `recipient_provider_key` + `recipient_identity_binding` | Exact provider key and 43-character opaque binding for immutable identity mode. Both are required together and `email` must be absent. | ## Error handling AuthProxy serves dedicated pages for each invitation error: | Page file | Condition | HTTP status | | --- | --- | --- | | `invitation-expired.html` | The token signature is valid, but the `exp` claim is in the past. | 401 | | `invitation-invalid.html` | The token is malformed or has an invalid signature. | 401 | | `invitation-select-provider.html` | The token is valid and multiple identity providers are configured. | 200 | | `invitation-subject-already-exists.html` | The authenticated subject is already associated with an existing account. | 409 | | `invitation-email-unavailable.html` | Legacy unsigned mode only: email binding is enabled, but the provider supplied no authenticated-session address. | 403 | | `invitation-email-mismatch.html` | Legacy unsigned mode only: email binding is enabled, and the provider supplied another address or explicitly reported `email_verified=false`. | 403 | See [Error pages](/authproxy/configuration/error-pages/) for customization details and [Custom Invitation Provider-Selection Page](/authproxy/configuration/invitation-provider-selection/) for a full branded provider-selection walkthrough. # Invitation to Organization > Invite a user into an existing organization through AuthProxy so they continue straight into the application after signing in. Use this flow when you invite a user into an organization that already exists. AuthProxy still uses the standard `/invite/` bootstrap, but a matching invitation tenant claim and the `ReturnUrl` destination let the user continue directly into the application instead of being sent to Lobby. ## Flow 1. The user opens `https://your-authproxy/invite/`. 2. AuthProxy validates the token, creates an independent 256-bit transaction and challenge, and calls `Invite.StageUrl`. The signed `invite-stage` attestation binds those values to the exact capability hash, invitation ID, and tenant before provider authentication starts. 3. AuthProxy protects the pending state in an HTTP-only cookie and binds it into the provider’s protected challenge state. A browser can carry the values but cannot author or substitute them. 4. After login, AuthProxy **re-validates the token** (signature, issuer, audience, and lifetime), the protected pending state, and the provider challenge binding. 5. AuthProxy requires one canonical provider identity, one provider-derived email with an exact verified value of `true`, one provider-derived assurance value, and the authentication-ticket issue time. 6. AuthProxy calls `Invite.ExchangeUrl` with a signed `invite-complete` attestation. The JSON body contains only the opaque transaction ID; the browser and request body never supply identity authority. 7. AuthProxy compares the configured `Invite.TenantClaim` from the token with the tenant resolved for the request. Equality is observational routing evidence, not issuer identity: it does not prove which tenant issued the invitation. 8. If the tenant values match, `Invite.MatchingTenantInvitationDestination` selects `ReturnUrl` or `Lobby`. `ReturnUrl` is the default and continues to the target service. If the tenant values differ, or AuthProxy cannot observe both values, the invitation selects Lobby when its frontend is configured. The matching-tenant enum does not change those rows of the shared routing matrix. ## Configuration ```json { "Cratis": { "AuthProxy": { "Invite": { "StageUrl": "https://lobby.example.com/_invite/stage", "ExchangeUrl": "https://lobby.example.com/_invite/exchange", "TenantClaim": "tenant_id", "MatchingTenantInvitationDestination": "ReturnUrl", "EmailClaim": "email", "Attestation": { "Issuer": "https://auth.example.com", "Audience": "ada-lobby", "ActiveKeyId": "invite-2026-08", "Lifetime": "00:01:00", "SigningKeys": [ { "KeyId": "invite-2026-08", "PrivateKeyPem": "" } ] }, "Lobby": { "Frontend": { "BaseUrl": "http://lobby-service:3000/" } } } } } } ``` | Property | Type | Description | | --- | --- | --- | | `StageUrl` | `string` | Absolute URL of the Lobby invitation authority’s pre-authentication staging endpoint. Required when `Attestation` is configured. | | `ExchangeUrl` | `string` | Absolute URL of the same Lobby invitation authority’s completion endpoint. | | `TenantClaim` | `string` | Claim in the invite token that contains the tenant ID observed for routing. Equality with the resolved tenant does not identify the invitation issuer. | | `MatchingTenantInvitationDestination` | `InvitationCompletionDestination` | Destination for matching tenant values. `ReturnUrl` is the default; use `Lobby` to select `Lobby.Frontend.BaseUrl`. | | `EmailClaim` | `string` | Claim in the invite token that contains the invited email. Required by the signed protocol. | | `Attestation.Issuer` | `string` | Exact issuer the invitation authority validates. | | `Attestation.Audience` | `string` | Exact invitation-authority audience. | | `Attestation.ActiveKeyId` | `string` | Key ID used for newly signed attestations. | | `Attestation.SigningKeys` | `array` | RSA private signing keys. Load private PEM values from a secret provider; pin only the matching public keys downstream. | | `Attestation.Lifetime` | `TimeSpan` | Short-lived token lifetime from 10 through 60 seconds. Defaults to 60 seconds. | | `Lobby.Frontend.BaseUrl` | `string` | Fallback redirect if the invite cannot continue directly into the organization. | ## Signed protocol The staging call uses `Authorization: Bearer ` and this bounded body: ```json { "invitationTransaction": "", "invitationToken": "", "invitationChallenge": "" } ``` The completion call uses `Authorization: Bearer ` and a body with no identity fields: ```json { "invitationTransaction": "" } ``` Both RS256 JWTs require `kid`, `iss`, `aud`, `jti`, `iat`, `nbf`, and `exp`. They bind `purpose`, `tenant_id`, `invitation_id`, `invitation_transaction`, `invitation_challenge`, and `capability_hash`. Every completion attestation additionally carries `provider_key`, `provider_issuer`, `provider_subject`, `assurance`, and `authenticated_at`. Email-targeted completion also carries `email` and `email_verified=true`. The same Lobby invitation authority owns both endpoints. It must independently validate the raw invitation during staging, compare its exact SHA-256 hash to `capability_hash`, persist no raw capability, and atomically consume the transaction and complete-attestation `jti` exactly once. It must reject a wrong purpose, signature, key ID, issuer, audience, lifetime, tenant, invitation, transaction, challenge, or capability hash. Configure [canonical federated identity](/authproxy/configuration/authentication/#canonical-federated-identity) for every provider. The selected email, verification, and assurance claim types must name provider-derived claims. Missing, duplicate, empty, unverified, or ambiguous evidence fails closed before the completion endpoint is called. Set `CanonicalIdentity.InvitationCompletionEnabled=true` only for providers that can supply that evidence. Signed invitation provider selection hides every other provider while leaving it available for ordinary sign-in. Microsoft Entra commonly omits `email_verified`; do not enable it unless the tenant maps an equivalent trustworthy custom claim and configures its exact claim type. AuthProxy never promotes `email` or `preferred_username` to verified evidence implicitly. Every signed invitation must select exactly one recipient-authority mode: either one nonempty invited-email claim, or the exact immutable provider-binding pair below. Missing, duplicate, partial, or mixed recipient claims are rejected before staging and again before completion. For a recipient already known by immutable provider identity, the signed invitation may carry both `recipient_provider_key` and `recipient_identity_binding`. The binding is a canonical 43-character base64url HMAC-SHA-256 value over the provider key, tenant-specific validated issuer, and immutable provider subject using the invitation authority’s documented length-delimited input format. AuthProxy validates the claim shape, restricts the chooser and callback to the exact provider key, and emits the provider key, validated issuer, subject, assurance, and authentication time without inventing email evidence. The invitation authority remains responsible for independently recomputing the opaque binding and comparing it to the staged capability before atomic consumption. Enable this route with `CanonicalIdentity.InvitationIdentityBindingCompletionEnabled=true`; email-targeted invitations continue to require `InvitationCompletionEnabled=true` and exact verified-email evidence. For Microsoft Entra identity-bound invitations, configure a tenant-specific authority and `CanonicalIdentity.SubjectClaimType=oid`. The framework-validated tenant issuer plus immutable object ID is the identity tuple; `email` and `preferred_username` are not substitutes. > **Compatibility.** Omitting `Invite.Attestation` retains the released unsigned JSON exchange for existing deployments. That legacy mode is not sufficient authority for creating or linking an account. Enable the signed protocol before an application treats invitation completion as identity proof. Independently, `Invite.MatchingTenantInvitationDestination` defaults to `ReturnUrl`, preserving the released matching-tenant redirect behavior. ## Rotate signing keys 1. Add the new public key to the invitation authority’s pinned verification set. 2. Add the new private key and key ID to `SigningKeys` without changing `ActiveKeyId`. 3. Deploy both sides, then switch `ActiveKeyId`. 4. Keep the previous public key until every token it signed has expired, then remove the old key from both sides. AuthProxy selects exactly one active key and always writes its `kid`. It never logs private key material or attestation payloads. ## Requirements * The invite token must include the claim configured in `Invite.TenantClaim`. * The request must resolve to the same tenant value after authentication. * The invitation link should use the same host and route shape that the organization’s normal traffic uses so tenant resolution produces the expected value. ## When to use another flow * If the invited user should create a new organization, use [Invitation for Creating Organization](/authproxy/configuration/lobby/invitation-for-creating-organization/). * If the user should self-register and create an organization without an invite, use [Registration](/authproxy/configuration/lobby/registration/). # Registration > Let a user start self-serve registration at the AuthProxy register endpoint and create a new organization without receiving an invite first. Use registration when a user should create a new organization without receiving an invite first. Users who are joining an existing organization should use an invitation flow instead. ## Flow 1. The user opens `https://your-authproxy/register`. 2. AuthProxy stores a short-lived HTTP-only registration cookie. 3. AuthProxy starts authentication. * With one configured identity provider, AuthProxy challenges that provider directly. * With multiple providers, AuthProxy redirects to the standard login page so the user can choose a provider. 4. After a successful login, AuthProxy deletes the registration cookie. 5. AuthProxy redirects the user to `Invite.Lobby.Registration.BaseUrl`. The `/register` path is only active when `Invite.Lobby.Registration.BaseUrl` is configured. ## Configuration ```json { "Cratis": { "AuthProxy": { "Invite": { "Lobby": { "Registration": { "BaseUrl": "https://lobby.example.com/register" } } } } } } ``` | Property | Type | Description | | --- | --- | --- | | `Lobby.Registration.BaseUrl` | `string` | Absolute URL for the lobby registration experience that creates the organization after AuthProxy sign-in. | ## Aspire When you configure AuthProxy through Aspire, set the registration destination with `WithLobbyRegistration(...)`: ```csharp authproxy.WithLobbyRegistration(lobbyResource, "/register"); // or use a raw URL authproxy.WithLobbyRegistration("https://lobby.example.com/register"); ``` This sets `Cratis:AuthProxy:Invite:Lobby:Registration:BaseUrl`. # Logout > Use the AuthProxy well-known logout endpoint to end the current session and send the user to a validated redirect destination. AuthProxy exposes a well-known logout endpoint that ends the current session and returns the user to a validated destination: ```text GET /.cratis/logout?redirect= ``` The endpoint is anonymous — it works even when the session is already invalid — and is handled before the authentication challenge stages, so it never bounces the user to a login provider. *** ## Full-chain logout A plain cookie logout leaves the user signed in at the identity provider, so the next visit silently re-authenticates. To avoid that, AuthProxy performs a **full-chain logout**: it ends the session both locally *and* at the identity provider using OIDC [RP-initiated logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). When the session was established through an **OIDC provider**, a logout request: 1. Reads the stored `id_token` and the provider the session was established with from the authentication cookie, and clears the local session (see [What it clears](#what-it-clears)). 2. Redirects (`302 Found`) the browser to that provider’s **end-session endpoint** (discovered from the provider’s OpenID configuration) with: * `id_token_hint` — the stored `id_token`, so the provider knows which session to end. * `post_logout_redirect_uri` — AuthProxy’s own callback, `/.cratis/logout/callback`. The validated final `redirect` target is carried across the round-trip in a short-lived, HTTP-only cookie (`.cratis-logout`) rather than in the URL. 3. The identity provider ends its own session and redirects back to `/.cratis/logout/callback`. 4. The callback clears every AuthProxy cookie again (idempotent) and redirects (`302 Found`) to the validated final `redirect` target. > **Register the callback with each OIDC provider.** `post_logout_redirect_uri` must be allow-listed at the provider, so register `https:///.cratis/logout/callback` as a permitted post-logout redirect URI for every OIDC application. ### OAuth 2.0 providers (e.g. GitHub) OAuth 2.0 providers have no standard OIDC end-session endpoint and cannot be force-logged-out via a redirect. When the session was established through an OAuth provider — or when there is no active OIDC session, or the provider’s discovery document advertises no end-session endpoint — AuthProxy falls back to a **local-only logout**: it clears its own cookies and redirects straight to the validated `redirect` target. The user’s session at the OAuth provider is left untouched, so a later visit may still re-authenticate silently without asking for credentials. This is a limitation of the OAuth providers, not of AuthProxy. *** ## What it clears Both the local logout and the post-logout callback: 1. Sign the user out of the authentication cookie (`.Cratis.AuthProxy.Auth.v2` unless you set [`Session:CookieName`](/authproxy/configuration/authentication/#session-lifetime-and-re-validation)), including any chunked variants. With `Session:CookieDomain` the deletion carries the domain, so it ends the session on every host under it, and the stale host-only copy an earlier deployment left behind is deleted too. 2. Delete every AuthProxy session cookie: * `.cratis-identity` * `.cratis-identity-authorization` * `.cratis-tenant` * `.cratis-tenants` * `.cratis-invite` * `.cratis-invite-state` * `.cratis-registration` * `.cratis-providers` 3. Delete every transient sign-in handshake cookie the browser sent (`.AspNetCore.Correlation.*`, `.AspNetCore.OpenIdConnect.Nonce.*`) — the leftovers of abandoned handshakes that would otherwise poison the next sign-in. See [Failed Sign-ins](/authproxy/configuration/failed-sign-ins/#handshake-cookie-hygiene). 4. Delete any **additional cookies** the deployment configured under `Cratis:AuthProxy:Logout:AdditionalCookies` — cookies AuthProxy does not issue itself but that must not survive a logout. See [Clearing additional cookies](#clearing-additional-cookies). The `.cratis-logout` carry cookie is deleted by the callback once the final target has been read from it. After logout nothing of the session survives, so the next visit to a protected resource lands on the provider-selection page (or the single provider’s login) rather than silently reusing anything local. Whether the *identity provider* still remembers the user is governed by the full-chain behavior above. *** ## The `redirect` parameter The post-logout destination is supplied as an **absolute URL** in the `redirect` query-string parameter, for example: ```text /.cratis/logout?redirect=https://cratis.studio ``` Because the target is absolute, it cannot be validated with the relative-URL check used elsewhere. Instead it is matched against an **allow-list of origins** so neither the endpoint nor its callback can be turned into an open redirect. The target is validated on both legs of the round-trip. A target is allowed when its origin (scheme + host + port) matches any of: * The proxy’s **own public origin** as seen by the browser (derived from the request, honoring `X-Forwarded-Proto`). This covers redirecting back to the site the user is already on. * Any configured **service frontend** (`Cratis:AuthProxy:Services::Frontend:BaseUrl`). * The configured **lobby frontend** (`Cratis:AuthProxy:Invite:Lobby:Frontend:BaseUrl`). * Any origin listed in **`Cratis:AuthProxy:Logout:AllowedRedirectOrigins`** (see below). * When [`SignIn:CallbackHost`](/authproxy/configuration/sign-in/#running-the-handshake-on-one-host) is configured, the origin of every host a service declares in `Hosts`, on the scheme the request arrived on. Without a callback host, hosts are not added: a deployment that serves several hosts without one lists them under `AllowedRedirectOrigins`. Same-site **relative** URLs (a single leading `/`, but not `//`) are always allowed. Addresses carrying user information (`https://user@host/`) are never allowed. If `redirect` is missing, empty, or fails validation, AuthProxy falls back to the application root (`/`). With a [callback host](/authproxy/configuration/sign-in/#running-the-handshake-on-one-host), a logout that ends at the identity provider and starts on another host is sent to the callback host first, because the identity provider returns the browser to one registered address and the carrier of the final destination is a host-only cookie. The session cookie is deleted there and is shared, so the person is signed out on every host under the cookie domain. *** ## Allowing additional post-logout origins The implicit origins above cover redirecting back to the app itself, but a deployment often wants to send the user somewhere else after logout — for example a separate marketing or landing site that is neither the proxy’s own origin nor a configured frontend. List those origins under `Cratis:AuthProxy:Logout:AllowedRedirectOrigins`. Each entry is an absolute origin (scheme + host, optionally a port) with no path; malformed or non-HTTP(S) entries are ignored. ```json { "Cratis": { "AuthProxy": { "Logout": { "AllowedRedirectOrigins": [ "https://cratis.studio" ] } } } } ``` Equivalent environment variables (one indexed key per entry): ```dotenv Cratis__AuthProxy__Logout__AllowedRedirectOrigins__0=https://cratis.studio ``` With the example above, `GET /.cratis/logout?redirect=https://cratis.studio` is permitted even when the app itself is served from a different host such as `https://app.cratis.studio`. *** ## Clearing additional cookies Logout clears every cookie *AuthProxy* issues — but a deployment often runs sibling authentication infrastructure whose cookies AuthProxy knows nothing about. The classic case is a separate [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) guarding an admin app whose session cookie was scoped to the **parent domain** (e.g. `_oauth2_proxy_admin` on `.cratis.studio`): every browser that ever signed in there sends it on every `*.cratis.studio` request, and it survives AuthProxy’s logout — leaving the user half-signed-in from the application’s point of view. List such cookies under `Cratis:AuthProxy:Logout:AdditionalCookies` and the session-termination sweep deletes them too. Each entry has: * **`Name`** *(required)* — the exact cookie name. Matching is by exact name only; there is no prefix or wildcard support. * **`Domain`** *(optional)* — the domain the cookie was scoped to, e.g. `.cratis.studio`. When set, the deletion is issued **for that domain as well as the request host** — necessary because a host-scoped deletion cannot touch a parent-domain cookie. Deleting for a parent domain of the current host is legal, which is exactly what makes this work. Leave it out for a cookie scoped to the request host itself. Deletions are issued at the root path (`Path=/`) with the `Secure` attribute mirroring the request scheme, so they reliably match how such cookies are typically written. ```json { "Cratis": { "AuthProxy": { "Logout": { "AdditionalCookies": [ { "Name": "_oauth2_proxy_admin", "Domain": ".cratis.studio" } ] } } } } ``` Equivalent environment variables (one indexed entry per cookie): ```dotenv Cratis__AuthProxy__Logout__AdditionalCookies__0__Name=_oauth2_proxy_admin Cratis__AuthProxy__Logout__AdditionalCookies__0__Domain=.cratis.studio ``` The configured cookies are cleared everywhere the session is terminated: * the logout endpoint (both legs of a full-chain logout), and * the sign-in sweep that runs when a provider callback completes — so a **fresh sign-in also heals** a browser still carrying the stale cookie, without requiring the user to log out first. This is a cleanup mechanism for cookies that linger after the owning deployment has been fixed (for example, until an old parent-domain cookie expires) — it does not log the user out of the system that issued the cookie. *** ## Example A “Log out” control in the application simply navigates the browser to the endpoint: ```html Log out ``` For an OIDC session the browser is taken through the provider’s end-session endpoint and back via the callback; for an OAuth session it lands on the target directly. Either way the user ends up on the target unauthenticated at AuthProxy, and requesting a protected resource then triggers the normal login flow. # Management Listener > Turn on the AuthProxy management listener, a second private socket for health and readiness probes that keeps the public port free of them. AuthProxy answers nothing about itself. There is no health endpoint, no readiness endpoint, no metrics path — every port it opens is the internet-facing one, and every request on it goes through authentication, tenancy and the reverse proxy. That is the right default for a proxy whose whole job is to be the front door, and it leaves a deployment with nothing to probe. The management listener is the opt-in answer: a **second, private socket** carrying two endpoints and nothing else. *** ## What a probe is actually testing today Without it, there are two things a deployment can point a probe at, and neither one says what it looks like it says. | Probe | What it proves | | --- | --- | | A TCP socket check | A process accepted a connection. Nothing about whether configuration bound, whether the Data Protection key ring initialized, or whether a single authenticated request could be served. | | An HTTP GET at an application path declared in [`AnonymousPaths`](/authproxy/configuration/services/#anonymous-paths) | The proxy *and* that application are both working. Genuinely useful — but it fails when the application is down, which is not the same question as whether the proxy should be restarted. | The gap is the middle case: an AuthProxy that starts, listens, and cannot serve anybody. A key ring that will not initialize is exactly that — the process is healthy by every external sign, and every authenticated request fails, because the key ring is what encrypts the session cookie and every AuthProxy-issued token. A TCP probe reports it healthy and keeps sending it traffic. *** ## Turning it on Name a port. Everything else has a default. ```json { "Cratis": { "AuthProxy": { "Management": { "Port": 9110 } } } } ``` That opens `http://127.0.0.1:9110` alongside the listener AuthProxy already serves traffic on, and answers two paths there: | Path | Answers | | --- | --- | | `/health/live` | `200` whenever the request loop is servicing requests. | | `/health/ready` | `200` when this instance can serve traffic, `503` when it cannot. | With Aspire: ```csharp builder.AddAuthProxy("authproxy") .WithManagementListener(9110); ``` **Leaving the section out changes nothing.** No second socket is opened, neither path exists, and AuthProxy binds exactly what it binds without this feature. This is a pure addition — there is nothing to migrate. *** ## Settings | Setting | Default | Meaning | | --- | --- | --- | | `Port` | *(none)* | The port the listener binds. **Required** — declaring the section without it is refused at startup. | | `BindAddress` | `127.0.0.1` | The address it binds. | | `LivePath` | `/health/live` | The path answering liveness. | | `ReadyPath` | `/health/ready` | The path answering readiness. | There is deliberately **no default port**. A port is a fact about your deployment: a value invented here would either collide with something you run or turn a private surface into a well-known one. The listener defaults to **loopback**, which is what makes it private. In Kubernetes that is reachable by the kubelet’s probes and by a sidecar in the same pod, and by nothing on the network. Widening `BindAddress` publishes both endpoints to everything that can route to the address — do it only when the probe genuinely runs elsewhere, and put a network policy in front of it. *** ## Liveness and readiness answer different questions This distinction is the whole point of having two endpoints, and getting it backwards causes outages. **Liveness consults nothing.** No dependency is resolved, no I/O is performed. It answers `200` for as long as the process is servicing requests — including while storage, the identity provider and every backend are unreachable. An orchestrator *restarts* what fails a liveness probe, so a liveness answer that depended on a dependency would turn somebody else’s outage into a restart loop of the one component that was still working. **Readiness verifies local capability only.** Today that means one thing: a `Protect`/`Unprotect` round-trip through Data Protection, which is the only mechanism that forces the key ring to initialize and proves it against the configured [key store](/authproxy/configuration/data-protection/) — a `DataProtectionKeysPath` directory, Azure Blob Storage or Redis. It is re-run on every call and no answer is cached, so a key ring that becomes unusable — a volume unmounted, a permission revoked — changes the answer. Readiness deliberately calls **no backend**, no `/.cratis/me` endpoint, no tenant-verification URL and no OIDC authority. A deployment whose every backend is down still becomes ready, because pulling the proxy out of rotation during a backend outage removes the component that would have served the error page. *** ## The endpoints are on that listener and nowhere else Both directions are enforced, and both matter. * **The management paths answer only on the management port.** Asking the public listener for `/health/live` gets the same `404` any unknown path gets. The private surface is not reachable from the internet, whatever the request claims. * **The management port answers only the management paths.** Anything else on it — `/`, an API path, `/.cratis/providers`, a path you declared anonymous, a bundled asset — gets that same `404`, and is never handed to the middleware pipeline or the reverse proxy. Whatever can reach this port bypassed the ingress by definition; it does not get to use it as a way around authentication. Isolation is decided by **the socket the request was accepted on**, not by the `Host` header. ASP.NET’s own port-scoping convention (`RequireHost("*:9110")`) matches that header, and a header is whatever the caller typed — a request to the public listener carrying `Host: anything:9110` would otherwise be served from the private surface. ### Your application keeps its own `/health` Nothing under `/health` is reserved globally. A service that serves its own health path and declares it in [`AnonymousPaths`](/authproxy/configuration/services/#anonymous-paths) keeps having it proxied exactly as before: ```json { "Cratis": { "AuthProxy": { "Services": { "main": { "AnonymousPaths": ["/health"] } } } } } ``` The one exception is the two exact management paths themselves. If your application serves something at `/health/live` or `/health/ready` and you want both, point the management listener elsewhere: ```json { "Management": { "Port": 9110, "LivePath": "/internal/alive", "ReadyPath": "/internal/serving" } } ``` *** ## What the endpoints say Very little, on purpose. A management endpoint is reachable without a credential by design, so everything it says is said to whoever can reach the port. Every answer is a short fixed body — `live`, `ready`, `not ready`, or the deployment’s uniform `Not Found` for the `404` — and names no provider, tenant, backend address, filesystem path, key identifier, version or assembly. A failing readiness answer discloses no more than a succeeding one: the reason is written to the log, where an operator reads it and a caller does not. No answer carries `Set-Cookie` or `WWW-Authenticate`, and AuthProxy never names Kestrel in a `Server` header on any response, whether or not this listener is open. The `404` is deliberately the *same* refusal the rest of the proxy writes, down to the byte. This middleware runs ahead of the [admission](/authproxy/configuration/admission/) gate, so a management path offered to the **public** listener is refused here and never reaches it — and a refusal of its own shape would tell an unadmitted caller that an AuthProxy is here, that it has a management listener, and what its paths are called. *** ## What is refused at startup The failure mode this feature has to avoid is a deployment that looks configured and answers no probe. Each of these is named at startup, pointing at the exact key: | Configuration | Why it is refused | | --- | --- | | The section with no `Port` | No listener opens, so every probe fails to connect — which reads exactly like the application being down. | | A `Port` the public listener already binds | A private listener that shares the public one is not private. | | A `Port` outside 1–65535 | Not a port. | | A `LivePath` or `ReadyPath` that is not rooted | Matches no request, so the listener answers its own probe the uniform `404`. | | `LivePath` and `ReadyPath` naming the same path | One of the two answers would be unreachable, and the one silently lost is readiness. | | A blank `BindAddress` | Names no address. Leave it unset for the loopback default. | The Aspire builder refuses an unusable port where the app host is built, so the mistake surfaces against the line of code that made it rather than at deployment. *** ## Kubernetes ```yaml livenessProbe: httpGet: path: /health/live port: 9110 periodSeconds: 10 readinessProbe: httpGet: path: /health/ready port: 9110 periodSeconds: 5 ``` The probe port must be declared as a container port for the kubelet to reach it, and it should **not** be exposed through a Service. *** ## Replacing an existing probe If you followed the guidance in [Unauthenticated Responses](/authproxy/configuration/unauthenticated-responses/) and pointed a probe at a declared anonymous path or downgraded it to TCP, this is the better answer for the proxy itself. Keep an application-path probe if you have one — it tests the proxy *and* the application end to end, which is a question worth asking — but point the *proxy’s own* liveness and readiness here, where they mean what their names say. On a deployment in [`CapabilityOnly`](/authproxy/configuration/admission/) admission mode this listener is the **only** probe available: every path on the public listener answers `404` until a capability has been presented, including one a service declared anonymous. # One proxy, several hosts > Put several applications on their own hosts behind one AuthProxy and one sign-in, with a shared session cookie and a single identity-provider callback. Say you run a console, an operations tool and a Chronicle workbench, each on its own host. Today that means three AuthProxy instances, three OAuth apps (or three callback URLs on one app), and three sign-ins: a person who signs in to the console is signed out on `operations.example.com`. This page puts all three behind one AuthProxy with one sign-in. It is a how-to for someone who already runs AuthProxy. For the individual settings, see [Services](/authproxy/configuration/services/), [Authentication](/authproxy/configuration/authentication/#session-lifetime-and-re-validation) and [Logout](/authproxy/configuration/logout/). ## What you end up with | Host | Service | Who may use it | | --- | --- | --- | | `console.example.com` | `console` | Any signed-in person | | `operations.example.com` | `operations` | Members of `Cratis/operations` | | `chronicle.example.com` | `chronicle` | Any signed-in person | | `auth.example.com` | none — runs the sign-in handshake | Everyone (optional, see [step 4](#4-run-every-handshake-on-one-host)) | A request for a host reaches that host’s service, and that service’s own [authorization requirements](/authproxy/configuration/authorization/) apply to it. One session cookie, scoped to `.example.com`, is honored on every host. ## 1. Give each service its hosts ```json { "Cratis": { "AuthProxy": { "Services": { "console": { "Hosts": [ "console.example.com" ], "Frontend": { "BaseUrl": "http://console-web:3000/" }, "Backend": { "BaseUrl": "http://console-api:8080/" } }, "operations": { "Hosts": [ "operations.example.com" ], "Frontend": { "BaseUrl": "http://operations-web:3000/" }, "Backend": { "BaseUrl": "http://operations-api:8080/" }, "Authorization": { "RequiredClaims": [ { "Claim": "urn:github:team", "AnyOf": [ "Cratis/operations" ] } ] } }, "chronicle": { "Hosts": [ "chronicle.example.com" ], "Frontend": { "BaseUrl": "http://chronicle-web:3000/" }, "Backend": { "BaseUrl": "http://chronicle-api:8080/" } } } } } } ``` AuthProxy refuses to start when two services claim the same host, or when a host is not a plain host name with an optional port. See [Routing by host or path prefix](/authproxy/configuration/services/#routing-by-host-or-path-prefix) for the precedence rules. From Aspire: ```csharp authProxy .WithHosts("console", "console.example.com") .WithHosts("operations", "operations.example.com") .WithHosts("chronicle", "chronicle.example.com"); ``` ### What your ingress has to do AuthProxy routes on the `Host` header it receives, so the ingress in front of it must forward every host to AuthProxy **and preserve the original `Host` header**. An ingress that rewrites it to one internal name sends every request to the same service. Set [`AllowedHosts`](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering) to the host names you serve, as well: it is the setting that makes AuthProxy answer only for them. AuthProxy warns at startup when a cookie domain is set and `AllowedHosts` accepts every host. ## 2. Share the session ```json { "Cratis": { "AuthProxy": { "Session": { "CookieDomain": ".example.com" } } } } ``` From Aspire: `authProxy.WithSessionCookieDomain(".example.com")`. The cookie now carries `Domain=.example.com`, so the browser sends it to every host under it. Choose the narrowest domain that covers your hosts. A domain cookie also goes to hosts that are **not** behind AuthProxy, and a broad domain such as `.com` widens who can replay it. AuthProxy refuses to start when: * a service declares a host outside the domain; * the domain is an IP address, a top-level domain, a wildcard, or has a port; * [tenants are resolved by `SubHost`](#why-the-cookie-domain-is-refused-with-subhost-tenancy). Nothing else about the session changes: the lifetime, the re-validation and the identity cookies behave as described in [Authentication](/authproxy/configuration/authentication/#session-lifetime-and-re-validation). The services’ own authorization still decides what each host lets a person do, so a session issued for `console` does not open `operations` for someone without the `Cratis/operations` claim. ## 3. Register the provider callbacks Without a callback host, every host runs its own handshake, so the identity provider has to know the callback of each one. For a GitHub OAuth app that provider is registered under the scheme `github`, and the callbacks are: ```text https://console.example.com/signin-github https://operations.example.com/signin-github https://chronicle.example.com/signin-github ``` Register all of them, or use a provider that accepts a wildcard redirect address. The post-logout address works the same way: each host’s `/.cratis/logout/callback` has to be registered with a provider that signs out at the provider. That is the whole setup when you only need one sign-in. If registering a callback per host is the part you want to get rid of, continue. ## 4. Run every handshake on one host ```json { "Cratis": { "AuthProxy": { "SignIn": { "CallbackHost": "auth.example.com" } } } } ``` From Aspire: `authProxy.WithSignInCallbackHost("auth.example.com")`. A sign-in that starts on `chronicle.example.com` is sent to `auth.example.com`, runs the whole handshake there, and returns to the page the person asked for on `chronicle.example.com`. The provider needs one callback, `https://auth.example.com/signin-github`, and one post-logout address. The session cookie is the one from step 2, so the person arrives signed in. `CallbackHost` needs `Session:CookieDomain`, and the host has to lie inside it; AuthProxy refuses to start otherwise. `auth.example.com` does not have to be declared as a service, but it has to reach AuthProxy. ### Where a sign-in may return to An absolute return address is an open redirect waiting to happen, so AuthProxy honors one **only** when a callback host is configured, and only when all of these hold: * the origin belongs to a host a service declares, or to a configured frontend, lobby or [`Logout:AllowedRedirectOrigins`](/authproxy/configuration/logout/#allowing-additional-post-logout-origins) entry; * the host lies inside the cookie domain, so the session follows the person there; * for a declared host, the scheme is the one the request arrived on and the port is the one the host declares, so an HTTPS sign-in is never returned to HTTP; * there is no user information in the address. Anything else is reduced to its path on the host the sign-in started on, or to `/`. Sign-out uses the same list. Without a callback host, return addresses are relative paths exactly as before. ### Invitations, links and registration stay on their host Invitation, credential-link and registration flows start their own challenge on the host the person is on, with cookies that belong to that host alone. A callback host does not move them, and the provider has to accept the callback of every host where one can start. If you only register the callback host, send people into those flows from that host. ## Migrating from several instances The old instances each issued their own host-only cookie, usually under the default name. After the move, a browser can hold the old host-only cookie and the new shared one under the same name. AuthProxy deletes the stale copy for you: * when a new session is issued, after the new cookie is written; * when a person signs out; * when the browser sends the session cookie twice; * when a session cookie arrives that cannot be read. It only ever deletes the host-only copy, never the shared one. If the request is for the cookie domain itself (for example `example.com` with `.example.com`), the browser may not distinguish the two, so nothing is deleted there. Three more things help: * **Rename the cookie.** `Session:CookieName` (Aspire: `WithSessionCookieName`) gives the new session its own name, so it can never be confused with an old one, and lets two AuthProxy deployments share a parent domain without reading each other’s sessions. Changing it signs everyone out once, and the old default-named cookie is deleted on the next sign-in and sign-out. The `__Host-` prefix is refused with a cookie domain, because browsers do not accept a domain on such a cookie. * **Delete cookies other deployments left.** [`Logout:AdditionalCookies`](/authproxy/configuration/logout/#clearing-additional-cookies) removes cookies AuthProxy does not own, including ones scoped to the parent domain. * **Watch header size.** A cookie on the parent domain travels with every request to every host under it. Sessions that carry many claims are chunked into several cookies, and an ingress with a small header limit (nginx `large_client_header_buffers`) can answer `400` for a browser that still holds the old chunks too. Raise the limit during the migration. ## Why the cookie domain is refused with SubHost tenancy With the [`SubHost` strategy](/authproxy/configuration/tenancy/#subhost-strategy-options) each tenant lives on its own subdomain, and the host-only cookie is what keeps a session for one tenant off the next tenant’s host. A cookie shared across the domain would carry it to all of them, and the tenant check only confirms that a tenant exists, not that the person belongs to it. AuthProxy therefore refuses to start with both, and there is no setting to override that. Resolve tenants some other way, or keep the host-only cookie. ## Limits to know about * **A host is a default, not a boundary.** A service-selection header or `?service=` still wins over the host, as it always has, so a frontend on one host can call another service’s backend by naming it. The other service’s authorization requirements apply to that call, so it cannot do anything the person could not do there, but its responses are readable by scripts on the first host. If two applications must not read each other’s data, they must not share an AuthProxy. * **`SameSite=Lax` does not separate sibling subdomains.** They are the same site, so a request from `console.example.com` to `operations.example.com` carries the cookie. Treat every host under the cookie domain as inside the same trust boundary. * **The `Host` header is the ingress’s to protect.** AuthProxy trusts the host it receives. Set `AllowedHosts` and keep the [trusted-proxy boundary](/authproxy/configuration/trusted-proxies/) declared. ## Check your work 1. Sign in on `console.example.com`, then open `chronicle.example.com` in the same browser. You should be signed in, without a provider redirect. 2. Open `operations.example.com` as someone without the claim. You should get the forbidden page, not the application. 3. Sign out on `chronicle.example.com`, then reload `console.example.com`. You should be signed out there too. 4. With a callback host, start a sign-in on `chronicle.example.com` and watch the address bar: it visits `auth.example.com` and ends on the page you asked for. 5. In the browser’s cookie storage, look for the session cookie on each host. There should be one, with `Domain=.example.com`, and no host-only copy. # Public Application Surfaces > Declare anonymous paths in AuthProxy so visitors with no account, such as magic-link or signed-URL pages, can reach parts of an application. Some parts of an application are served to someone who has no account and never will: a magic-link landing page, a report reached by a signed URL, a public status page. The visitor is not anonymous to the *application* — the token in their link identifies them — but they are anonymous to the **proxy**, which has no session to check. Declaring those paths is what [`AnonymousPaths`](/authproxy/configuration/services/#anonymous-paths) is for. > **Not on a closed deployment.** Everything on this page describes a deployment in the default `Public` admission mode. Under [`CapabilityOnly`](/authproxy/configuration/admission/) a declared anonymous path answers the uniform `404` like everything else, because admission runs before authentication and decides whether there is anything here to reach at all. A deployment that needs both a genuinely public surface and a closed one needs two deployments. *** ## Declaring the surface Name the specific paths the application serves without a session — the landing route that exchanges the token, and the API routes it calls: ```json { "Cratis": { "AuthProxy": { "Services": { "core": { "Frontend": { "BaseUrl": "http://core-web:3000/" }, "Backend": { "BaseUrl": "http://core:8080/" }, "AnonymousPaths": [ "/customer-portal", "/api/customer-portal" ] } } } } } ``` `/customer-portal` is served by the frontend, `/api/customer-portal` by the backend — the split follows the same `/api` rule the authenticated routes use. ### Declare the narrowest prefix that works Entries are prefixes and cover everything beneath them. `/api/customer-portal` opens every route under it, including ones added later by someone who does not know the prefix is public. If only some of those routes are public, name them individually instead. *** ## What still protects the surface The token in the link. AuthProxy does not check it — it cannot, because only the application knows how it was minted. The application validates it on every request, exactly as it would if the proxy were not there. What the proxy still does: * Strips every inbound `x-ms-client-principal*` header and the tenant header (`x-cratis-tenant-id`, and the legacy `Tenant-ID`), so a caller cannot assert an identity on the way in. * Injects no principal headers, because there is no session. * Leaves an authenticated visitor authenticated. A declared path is identity-*optional*, not identity-free: someone who happens to be signed in still arrives with their identity headers, though **without** an `x-cratis-tenant-id` if they have not selected a tenant. Handle a declared path as tenant-optional. *** ## Why declare it in the proxy rather than route around it The alternative is an ingress rule above AuthProxy sending those paths straight to the service. It works, and it costs two things worth understanding before choosing it. **It moves the identity-trust boundary.** Bypassing the proxy makes the service directly reachable, so the ingress rule must strip inbound `x-ms-client-principal*`, `x-cratis-tenant-id` and `Tenant-ID` headers itself. Stripping and re-injecting those headers is precisely AuthProxy’s job; a carve-out that forwards them unfiltered is an authentication bypass. The rule now has to be as correct as the proxy, and it is maintained somewhere else, by someone else. **It weakens what you can say about the deployment.** “Public traffic reaches AuthProxy only” becomes “public traffic reaches AuthProxy except for this list”, and the list has to be re-audited whenever it changes. Declaring the paths inside the proxy has neither cost. Traffic still flows through AuthProxy, so the header boundary holds and the invariant stays whole. *** ## Checking it works An unauthenticated request to a declared path should reach the application. If it comes back as the sign-in chooser, the declaration was not accepted — an entry that is not a rooted path of literal segments is discarded silently, so check its spelling first. See [Anonymous paths](/authproxy/configuration/services/#anonymous-paths) for the exact rule. If it comes back as a bare `404 Not Found` and so does every other path — including `/.cratis/providers` — the deployment is in [`CapabilityOnly`](/authproxy/configuration/admission/) admission mode, and no declaration will open it. If the request comes back as `401` rather than the application’s response, the caller was not treated as a browser navigating to a page. That is expected for a `fetch()` or a command-line client against an *undeclared* path — see [Unauthenticated responses](/authproxy/configuration/unauthenticated-responses/). For the sibling case, an inbound request from a system rather than a person, see [Receiving webhooks](/authproxy/configuration/webhooks/). # Search-Index Exclusion > Opt out of search indexing for every host served by an AuthProxy deployment. Authentication protects application content, but a search engine can still index a sign-in page or a URL that answers `401`. Enable `Cratis:AuthProxy:SearchIndexing:Exclude` to ask search engines to keep all hosts served by this deployment out of their indexes. The setting defaults to `false`. Without it, AuthProxy adds no search-index headers and `/robots.txt` follows the same authentication and routing rules as any other application path. ## Enable exclusion Add this section to your AuthProxy configuration: ```json { "Cratis": { "AuthProxy": { "SearchIndexing": { "Exclude": true } } } } ``` For environment-variable configuration, set `Cratis__AuthProxy__SearchIndexing__Exclude=true`. In an Aspire AppHost with an existing AuthProxy resource, call: ```csharp authProxy.WithSearchIndexExclusion(); ``` This is a deployment-wide setting, not a service or host override. [`Services:Hosts`](/authproxy/configuration/one-proxy-several-hosts/) selects a service, while sign-in pages, errors and other proxy-owned responses belong to the shared proxy. Use separate deployments if some hosts must remain indexable. ## Response contract When enabled: * `GET /robots.txt` answers anonymously with `200`, `Content-Type: text/plain; charset=utf-8` and exactly `User-agent: *\nDisallow: /\n` (each `\n` is a newline). * `HEAD /robots.txt` answers with the same headers and no body. * Those two requests never authenticate, resolve a tenant or reach an upstream. The match is the exact, case-sensitive path; child paths and other methods keep their existing authentication behavior. * AuthProxy supplies `X-Robots-Tag: noindex, nofollow` on responses without an existing `X-Robots-Tag`: proxied responses, `401` refusals, sign-in and OAuth redirects, error and well-known pages, static assets, and management responses. **An upstream’s existing value wins**, even if it permits indexing. AuthProxy does not replace or append to that value; keep upstream policies consistent with your intent. The robots file is deliberately public even with [`CapabilityOnly` admission](/authproxy/configuration/admission/). Opting in therefore reveals that this one path exists; every other path keeps its admission behavior. Private management-listener isolation still runs first, so that listener never serves the robots file and management paths do not become public. These are requests to cooperative crawlers, not access controls or a guarantee of removal from an existing index. Keep authentication enabled, and use the search engine’s removal tools when needed. ## Check the deployment Request `/robots.txt` without a session and check its body and content type. Request a protected API without a session and check that its `401` includes `X-Robots-Tag: noindex, nofollow`. For a proxied response, check whether the application supplies its own policy before expecting the default header. # Services > Configure the services AuthProxy routes to with YARP, each with a backend, a frontend or both, plus routing, hosts and path prefixes. AuthProxy routes requests to one or more **services** using [YARP](https://microsoft.github.io/reverse-proxy/). Each service may expose a **backend** (API), a **frontend** (SPA / static assets), or both. *** ## Configuration Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly name: ```json { "Cratis": { "Services": { "portal": { "Backend": { "BaseUrl": "http://portal-api:8080/" }, "Frontend": { "BaseUrl": "http://portal-web:3000/" }, "ResolveIdentityDetails": true, "AnonymousPaths": [ "/welcome", "/api/webhooks/payments" ], "ClientCredentials": { "RoutePrefix": "/api", "VerificationPath": "/.cratis/client-credentials/verify" } }, "catalog": { "Backend": { "BaseUrl": "http://catalog-api:8080/" } } } } } ``` ### ServiceConfig properties | Property | Type | Default | Description | | --- | --- | --- | --- | | `Backend` | `ServiceEndpointConfig` | `null` | API backend endpoint. | | `Frontend` | `ServiceEndpointConfig` | `null` | SPA / static-asset frontend endpoint. | | `Hosts` | `string[]` | `[]` | Host names (with an optional port) that route to this service. See [Routing by host or path prefix](#routing-by-host-or-path-prefix). | | `PathPrefix` | `string` | `""` | Path prefix that routes to this service, for example `/reporting`. See [Routing by host or path prefix](#routing-by-host-or-path-prefix). | | `StripPathPrefix` | `bool` | `false` | Remove `PathPrefix` from the forwarded path and send it in `X-Forwarded-Prefix`. | | `ResolveIdentityDetails` | `bool?` | `true` when Backend is set | Whether to call `/.cratis/me` on this service **at all**. See [Identity enrichment](#identity-enrichment). | | `IdentityVerification` | `Required` \| `BestEffort` | `Required` | What that call’s answer **means**. `Required` fails closed; `BestEffort` is the explicit opt-in for an endpoint that only enriches. See [Identity enrichment](#identity-enrichment). | | `IdentityVerificationTimeout` | `TimeSpan` | `00:00:10` under `Required` (the default), unbounded under `BestEffort` | How long to wait for the answer. Zero or negative leaves the wait unbounded. See [Two settings, two questions](#two-settings-two-questions). | | `ActivityTimeout` | `TimeSpan` | The root `ActivityTimeout`, then `00:05:00` | How long a request proxied to this service may sit idle before AuthProxy cancels it. See [Timeouts and streaming](#timeouts-and-streaming). | | `AnonymousPaths` | `string[]` | `[]` | Path prefixes on this service served to unauthenticated callers. See [Anonymous paths](#anonymous-paths). | | `ClientCredentials` | `ServiceClientCredentialsConfig` | `null` | Enables back-channel client-credentials verification and token minting for this service. | | `BearerRoutes` | `BearerRouteConfig[]` | `[]` | Path prefixes authenticated by an access token from an external authorization server instead of a browser session. See [Bearer routes](#bearer-routes). | | `AccessToken` | `ServiceAccessTokenConfig` | `null` | Forwards the signed-in user’s access token for this service’s audience to its backend. See [Forwarding the user’s access token](#forwarding-the-users-access-token). | ### ServiceEndpointConfig properties | Property | Type | Description | | --- | --- | --- | | `BaseUrl` | `string` | Base URL of the endpoint (e.g. `http://my-service:8080/`). | | `ActivityTimeout` | `TimeSpan` | Idle limit for this endpoint alone (`Backend` or `Frontend`). Overrides the service and root values. See [Timeouts and streaming](#timeouts-and-streaming). | ### ServiceClientCredentialsConfig properties | Property | Type | Description | | --- | --- | --- | | `RoutePrefix` | `string` | Route prefix that AuthProxy-issued bearer tokens are allowed to access (for example `/api`). | | `VerificationPath` | `string` | Internal verification endpoint. Relative values are resolved against `Backend.BaseUrl`; absolute values are used as-is. | *** ## Routing ### Single service When only one service is configured, and it declares neither `Hosts` nor a `PathPrefix`, AuthProxy adds a plain catch-all route so the service is reachable without any special routing header or query parameter. * `/{**path}` → frontend * `/api/{**path}` → backend ### Multiple services With more than one service, a request reaches a service when the service declares the request’s host or path prefix (see below), or when the client names the service with one of: | Mechanism | Example | | --- | --- | | `x-cratis-microservice` request header | `x-cratis-microservice: portal` | | `service` query parameter | `?service=portal` | `x-cratis-microservice` is the header an Arc frontend sends for HTTP requests when it sets a microservice. The `Service-ID` header earlier releases used is still accepted inbound and means the same thing; `x-cratis-microservice` wins when both are sent. A header naming an unknown service does not match a route, so a valid `?service=` can still select the target and its authorization requirements. Arc’s WebSocket and SSE observable connections instead send `?x-cratis-microservice=` by default. AuthProxy does not route on that query argument yet. In a multi-service deployment, configure Arc with `Globals.microserviceWSQueryArgument = 'service';` so observable connections use `?service=`. The selected identifier is forwarded under both header names, including when selected by `?service=`. Forwarding `Service-ID` is deprecated and will be removed in a future major release; move backends to `x-cratis-microservice`. Routes are matched case-insensitively. Within a service, `/api/...` goes to the backend and everything else goes to the frontend. With host routing, path-prefix routing or the unrestricted single-service catch-all, a service with only a backend receives every path within that route. Header and query selection of a backend-only service only match `/api/...`. ### Routing by host or path prefix A browser cannot put a header on a top-level navigation, and adding `?service=` to every URL (assets, deep links, bookmarks) is impractical. To put several applications behind one AuthProxy, and so behind one sign-in, give each service a host, a path prefix, or both: ```json { "Cratis": { "AuthProxy": { "Services": { "portal": { "Hosts": [ "portal.example.com" ], "Frontend": { "BaseUrl": "http://portal-web:3000/" }, "Backend": { "BaseUrl": "http://portal-api:8080/" } }, "reporting": { "PathPrefix": "/reporting", "StripPathPrefix": true, "Frontend": { "BaseUrl": "http://reporting-web:3000/" }, "Backend": { "BaseUrl": "http://reporting-api:8080/" }, "ClientCredentials": { "RoutePrefix": "/reporting/api" } } } } } } ``` Here `https://portal.example.com/orders` goes to the portal frontend, `https://portal.example.com/api/orders` to the portal backend, `https://portal.example.com/reporting/api/sales` to the reporting backend as `/api/sales`, and `https://any-host/reporting/dashboard` to the reporting frontend as `/dashboard`. #### Precedence When more than one rule could match a request, the first one in this list wins: 1. [Anonymous paths](#anonymous-paths). 2. A `PathPrefix` on one of the service’s `Hosts`. 3. A `PathPrefix` on a service without `Hosts`, which matches on every host. 4. The `x-cratis-microservice` header (or legacy `Service-ID`), then the `service` query parameter. 5. `Hosts` on a service without a `PathPrefix`. 6. The single-service catch-all routes. A path prefix claims its part of the URL, so it wins over a header or query parameter naming another service. A host is only a default for the requests on it: a frontend served from `portal.example.com` can still call another service’s backend by naming it in `x-cratis-microservice`, as Arc frontends do. The service a request is routed to is also the service whose [authorization requirements](/authproxy/configuration/authorization/) apply to it, and the only service whose [client-credentials](#client-credentials) tokens it accepts. The checks use the selected proxy route, so a host or prefix cannot be used to reach a service without meeting its requirements. Client-credentials `RoutePrefix` must include the external path prefix; see [Client credentials](#client-credentials). #### Ambiguous matches fail at startup AuthProxy refuses to start, and names the services involved, when: * two services without a `PathPrefix` declare the same host (`example.com` without a port overlaps every `example.com:port`); * two services declare equal or nested path prefixes (`/reports` and `/reports/archive`) on the same hosts, or both on every host; * a `Hosts` entry is not a host name with an optional numeric port. URLs, paths, IPv6 literals and wildcards (`*.example.com`) are refused; * a `PathPrefix` is not a rooted path of literal segments, is `/api` or below it, or covers a path AuthProxy reserves for itself (`/.cratis`, `/_pages`, `/invite`, `/register`, `/signin-*`); * a service declares `Hosts` or a `PathPrefix` but has no `Backend` or `Frontend`, or sets `StripPathPrefix` without a `PathPrefix`. A prefix on some hosts and a prefix on every host may overlap. The host-specific one wins on its hosts. #### Keeping or stripping the prefix By default the service receives the path as requested, `/reporting/api/sales`, and serves itself under the prefix. In ASP.NET Core that is `app.UsePathBase("/reporting")`, and a single-page frontend builds with the same base path. With `StripPathPrefix` the prefix is removed: the service receives `/api/sales`, and AuthProxy sends the removed prefix in `X-Forwarded-Prefix`. A backend that honors forwarded headers restores the removed prefix as its path base, so links and redirects it generates still point under `/reporting`. An `X-Forwarded-Prefix` sent by a proxy in front of AuthProxy is replaced, not combined. [Anonymous paths](#anonymous-paths) below a stripped prefix are stripped too. Declare them with the full path, for example `/reporting/public`. AuthProxy’s own endpoints (`/.cratis/login`, `/.cratis/select-provider`, `/.cratis/logout` and the other `/.cratis/*` paths it answers itself) stay at the root on every host. A frontend served under a prefix calls them at the root. `/reporting/.cratis/me` is forwarded to the reporting service like any other path under its prefix. WebSocket upgrades and server-sent events follow the same routes as any other request. #### Several hosts, one sign-in A host routes a request; it does not isolate one service from another. A service-selection header or `?service=` still beats the host, so a frontend served from one host can call another service’s backend by naming it, and that service’s [authorization requirements](/authproxy/configuration/authorization/) apply to the call. Its response is readable by scripts on the first host, and `SameSite=Lax` does not separate sibling subdomains. Do not put applications that must not read each other’s data behind one AuthProxy. To make the hosts share one session, set `Session:CookieDomain`, and to run every sign-in handshake on one host, `SignIn:CallbackHost`. See [One proxy, several hosts](/authproxy/configuration/one-proxy-several-hosts/). From Aspire, declare the routes with `WithHosts` and `WithPathPrefix`: ```csharp authProxy .WithHosts("portal", "portal.example.com") .WithPathPrefix("reporting", "/reporting", stripPathPrefix: true); ``` *** ## Timeouts and streaming Everything AuthProxy forwards — a plain request, a WebSocket session, a Server-Sent Events (SSE) stream — is subject to one limit, the **activity timeout**: the longest a proxied request may sit idle, with no bytes moving in either direction, before AuthProxy cancels it. The clock restarts whenever data is read or written, so it is an idle limit, not a cap on how long a connection may live. The default is five minutes. Set it in three places. The most specific one that is set wins: | Setting | Applies to | | --- | --- | | `Cratis:AuthProxy:Services:::ActivityTimeout` | That endpoint only. | | `Cratis:AuthProxy:Services::ActivityTimeout` | Both endpoints of that service. | | `Cratis:AuthProxy:ActivityTimeout` | Every endpoint that states nothing narrower. | ```json { "Cratis": { "AuthProxy": { "ActivityTimeout": "00:10:00", "Services": { "portal": { "Backend": { "BaseUrl": "http://portal-api:8080/", "ActivityTimeout": "01:00:00" }, "Frontend": { "BaseUrl": "http://portal-web:3000/" } }, "reporting": { "Backend": { "BaseUrl": "http://reporting-api:8080/" }, "ActivityTimeout": "00:02:00" } } } } } ``` Here `portal`’s backend allows an hour of silence, its frontend and anything not listed allow ten minutes, and `reporting` allows two. As environment variables the root value is `Cratis__AuthProxy__ActivityTimeout` and a service’s is `Cratis__AuthProxy__Services__portal__ActivityTimeout`. A value must be at least one millisecond and at most 2,147,483,647 milliseconds (about 24 days). AuthProxy refuses to start when a value is outside this range, and the message names the setting. `Registration` is not a proxied endpoint: setting `Registration:ActivityTimeout` also prevents startup. Remove that setting and configure the service’s `Backend` or `Frontend` activity timeout instead. `Invite:Lobby` does not create proxy clusters: setting `ActivityTimeout` on the lobby itself or its `Backend`, `Frontend` or `Registration` endpoints also prevents startup with a message naming the setting. Remove those settings and configure the proxied service under `Services` instead. A change to the configuration file is applied to new requests without a restart. From Aspire: ```csharp authproxy.WithActivityTimeout(TimeSpan.FromMinutes(10)); authproxy.WithServiceActivityTimeout("reporting", TimeSpan.FromMinutes(2)); ``` ### WebSocket and Server-Sent Events AuthProxy forwards a WebSocket upgrade or an SSE response to the service as-is and does not buffer, compress or rewrite the stream, so each message reaches the client as soon as the service writes and flushes it. What to know: * **A quiet stream is cut.** If the service sends nothing for longer than the activity timeout, and the client sends nothing either, AuthProxy cancels the request and the client sees the connection drop. For a live-update feature such as an observable query, either send a heartbeat (an SSE comment line such as `: ping`, or a WebSocket ping) more often than the timeout, or raise the timeout above the longest silence you expect. A heartbeat at a third to a half of the timeout leaves room for one lost beat. * **A stream is authorized when it opens.** The session cookie and any access policy are evaluated on the upgrade or SSE request, and the identity headers are attached to it. Nothing is re-checked while the stream stays open, so a user whose access is revoked keeps a connection that is already open until it closes. Keep streams bounded, or have the service close them periodically, if that window matters. * **Whatever sits in front of AuthProxy has its own idle limit.** A load balancer, gateway or hosting platform in front of AuthProxy applies its own timeout, and the shortest limit on the path wins. Raising the AuthProxy value alone does not help if the platform cuts the connection first, so align the two, or rely on heartbeats that are more frequent than both. * **The same applies behind AuthProxy.** A service or sidecar between AuthProxy and the application may have an idle limit of its own. * **Reconnect on the client.** Browsers reconnect an `EventSource` on their own; a WebSocket client needs its own reconnect logic. A dropped stream is the normal way an idle one ends. *** ## Anonymous paths By default every path behind AuthProxy requires a session. An unauthenticated request is answered by the provider-selection page if it is a browser navigation, or [refused with a status code](/authproxy/configuration/unauthenticated-responses/) if it is not — and anything that does reach the reverse proxy is refused by the default authorization policy. `AnonymousPaths` declares the paths a service genuinely serves without a session: a magic-link landing page, a signed-token report, a public webhook receiver. A declared path is reachable by anyone who knows the URL — AuthProxy stops demanding a login, it does not authenticate the caller. The application remains responsible for deciding whether to trust the request. Two how-to guides cover the cases in detail: [Public application surfaces](/authproxy/configuration/public-surfaces/) for a page or API a person reaches without an account, and [Receiving webhooks](/authproxy/configuration/webhooks/) for a request from another system. ```json { "Cratis": { "AuthProxy": { "Services": { "portal": { "Frontend": { "BaseUrl": "http://portal-web:3000/" }, "Backend": { "BaseUrl": "http://portal-api:8080/" }, "AnonymousPaths": [ "/welcome", "/api/webhooks/payments" ] } } } } } ``` From Aspire: ```csharp authProxy.WithAnonymousPaths("portal", "/welcome", "/api/webhooks/payments"); ``` ### Matching Each entry is a **path prefix**, matched case-insensitively on segment boundaries — the same semantics as the built-in invite, registration and authentication-UI paths. | Declared | Matches | Does not match | | --- | --- | --- | | `/welcome` | `/welcome`, `/welcome/`, `/WELCOME`, `/welcome/abc/def` | `/welcomex`, `/app/welcome` | | `/api/webhooks/payments` | `/api/webhooks/payments/...` | `/api/webhooks`, `/api/webhooks/invoices` | Because an entry covers everything below it, name the specific leaf path whenever a sibling under the same parent is not public. ### What a valid entry looks like An entry must be a rooted path whose segments are made only of the characters `A–Z`, `a–z`, `0–9`, `-`, `.`, `_` and `~`. Anything else is **refused**, leaving that path authenticated. Refusing rather than interpreting is deliberate, and every rule below is a case where a declared prefix would otherwise have meant one thing to the reader and another to the proxy: | Refused | Example | Why | | --- | --- | --- | | Blank, whitespace, or the bare `/` | `""`, `"/"`, `"///"` | An empty prefix matches *every* request and would turn the whole service anonymous — the worst outcome this feature can produce. | | Unrooted | `welcome` | Not a path prefix. | | An empty segment | `/a//b` | Not a legal route template. | | A `.` or `..` segment | `/public/../admin` | Reads as scoped to `/public` while naming `/admin`. Refused rather than resolved, so a prefix always means what it spells. | | Any character outside the permitted set | `/a{x}`, `/a*`, `/a?b`, `/a;b`, `/a:b`, `/a@b`, `/a\b` | `{`, `}` and `*` would make the router match `/aANYTHING/…` where the middlewares match only the literal. The rest are separators or delimiters to some parsers and literals to others. | | Percent-encoding | `/public%2fadmin`, `/public/%2e%2e/admin` | A prefix whose meaning depends on encoding cannot be reasoned about — and these are the classic separator-smuggling and traversal forms. | | Control characters or whitespace | `/a b`, `/a\tb` | Invisible differences between two entries that read identically, and the raw material of log and header injection. | | Non-ASCII characters | `/públic` | The same path has more than one Unicode spelling (`NFC` vs `NFD`), so which one is anonymous would depend on how the configuration file was saved. | | A path AuthProxy answers itself | `/.cratis`, `/.cratis/token`, `/_pages`, `/invite`, `/register`, `/signin-microsoft` | These do not become “more public” — they take the endpoint *away* from AuthProxy and hand it to a backend. | A dot *inside* a segment is fine, so `/.well-known/acme-challenge` and `/public/health.json` are both valid. Only a segment that is exactly `.` or `..` is refused. A refused entry is reported at startup as a warning naming the entry and the reason, so a declared path that still returns the selection page can be diagnosed from the log rather than by inspection. `/api` chooses the endpoint the same way the authenticated routes do: a prefix under `/api` is served by the service’s `Backend`, anything else by its `Frontend`, falling back to whichever endpoint the service actually declares. Under a service’s `PathPrefix`, the same split applies relative to that prefix: an anonymous `/reporting/api/webhook` goes to the reporting backend. ### What it does and does not change * The request still travels through AuthProxy. Inbound `x-ms-client-principal`, `x-ms-client-principal-id`, `x-ms-client-principal-name` and `x-cratis-tenant-id` (and the legacy `Tenant-ID`) headers are stripped as they are for every other request, so a caller cannot assert an identity on an anonymous path. * No principal headers are injected for a caller with no session. A caller that *does* present a valid session is still authenticated normally and still gets its identity headers — the path is identity-*optional*, not identity-free. * A signed-in caller reaches a declared path **without an `x-cratis-tenant-id` header** when they have not chosen a tenant, because tenant selection is skipped along with everything else. Handle a declared path as tenant-optional: it already has to work for a caller with no identity at all, so identity without a tenant is a strictly better-informed case of the same thing. * The application remains responsible for authorizing these paths. This only stops the proxy from demanding a login before the application is ever reached. * A declared prefix is claimed for the whole proxy. An anonymous caller cannot send an `x-cratis-microservice` header, so the path itself identifies the service — in a multi-service deployment no other service can serve anything under a declared prefix. If two services declare the same prefix, the first one in configuration order serves it; the path stays anonymous, which is what both asked for. * A declared path is reachable for *every* caller, not only signed-out ones. Provider selection, the unresolved-tenant refusal and the tenant-selection page are all skipped for it, so a user who happens to be signed in — without having chosen a tenant — still gets the application’s response rather than a chooser page. *** ## Identity enrichment For each service with a `Backend` endpoint (and `ResolveIdentityDetails` not explicitly set to `false`), AuthProxy calls `GET {Backend.BaseUrl}/.cratis/me` after authentication. The response decides admission (its `isAuthorized` verdict) and supplies `details`, which AuthProxy merges into the readable `.cratis-identity` cookie so a frontend can render the signed-in user without a round-trip. See [The two identity cookies](/authproxy/configuration/authentication/#the-two-identity-cookies). The response is **not** forwarded to backends. The `x-ms-client-principal` header is built only from the authenticated session — the identity provider’s claims plus the `anonymous` and `authenticated` roles. A `roles` field in the `/.cratis/me` response is ignored, so a service cannot assert roles that other services would then trust. To gate UI on roles the application decides itself, have the frontend read the application’s own `/.cratis/me`, which AuthProxy passes through to it; Arc’s identity provider does this. What exactly your service receives — the four identity headers, the guarantee that every value is US-ASCII, the `x-ms-client-principal-name*` sibling for names that are not, and why `x-ms-client-principal` stays the canonical value — is described in [Forwarded identity headers](/authproxy/configuration/authentication/#forwarded-identity-headers). ### Two settings, two questions `ResolveIdentityDetails` decides whether the endpoint is **called**. `IdentityVerification` decides what its answer is **worth**. They are separate because they are separate questions, and they have opposite failure directions. Asking a service “what else do you know about this user” is enrichment: if the service is down, the right answer is to carry on without the extra details. Asking it “is this user allowed in at all” is verification: if the service is down, the only safe answer is no. A single flag cannot express both, so the mode is per service. | Mode | Meaning | Use for | | --- | --- | --- | | `Required` (default) | The endpoint decides. Only an explicit positive admits. | Any service that answers `/.cratis/me` with an authorization verdict. | | `BestEffort` | The endpoint enriches. Only an explicit `403` denies. | A service that contributes display details — profile, preferences, feature flags — and that you accept being asked in vain. | `Required` is the default for every service that has a `Backend` and has not set `ResolveIdentityDetails` to `false`: a proxy that does not know whether its identity service is up must not assume the answer is yes. A service that is never called — no `Backend`, or `ResolveIdentityDetails: false` — has no answer to fail, so the setting has no effect on it. `BestEffort` is an explicit opt-in. Under it the proxy reads no verdict out of a successful response body at all, it takes `details` and forwards the caller, so a body saying `"isAuthorized": false` is **not** a refusal — the caller is admitted and those details are merged. Plenty of services answer that for reasons that are not about access at all: an account mid-onboarding, a lapsed trial, a profile the frontend renders a banner for. Opt in only when that is what you mean. Earlier releases defaulted to `BestEffort`; see [Upgrading](/authproxy/upgrading/identity-verification-required-by-default/) for what changed and how to keep the old behavior. `Required` also decides how long the call may take. See [What each outcome does](#what-each-outcome-does) for the timeout that follows from it. ### What each outcome does | The service… | `BestEffort` | `Required` | | --- | --- | --- | | answers `200` with an unambiguous positive verdict | forward, merge details | forward, merge details | | answers `403` | **deny** | **deny** | | answers `200` with a verdict of “not authorized” | forward, merge details | **deny** | | cannot be reached (DNS, connection refused, TLS) | forward | **deny** | | does not answer within `IdentityVerificationTimeout` | forward | **deny** | | is still answering when the caller goes away | forward | **deny** | | answers any other non-`2xx` (400, 401, 404, 500, 502, 503…) | forward | **deny** | | answers `204`, or `200` with an empty or blank body | forward | **deny** | | answers a body that will not parse as JSON | forward | **deny** | | answers well-formed JSON carrying no verdict | forward, merge details | **deny** | | answers `200` with contradicting verdicts | forward, merge details | **deny** | An **unambiguous positive** is a body carrying `isAuthorized: true` as a JSON boolean, and not contradicting it with `isAuthenticated: false`. A response that carries only details states no verdict — which is exactly right for a service being asked only to enrich, and never enough for one being asked to decide. Property names are matched without regard to casing; a quoted `"true"` is not a verdict. Read the `BestEffort` column as one sentence: **`403` denies, everything else is forwarded and whatever details arrived are merged.** That is what earlier releases did for every service, and it is why it is now an opt-in rather than the default: it admits a caller exactly when the proxy has learned the least. ### How long the call may take `IdentityVerificationTimeout` states the wait. Leave it unset and the mode decides, because a bound on the wait is a property of a decision, not of enrichment: | Mode | Unset timeout means | | --- | --- | | `Required` (default) | Ten seconds. A service standing between a caller and a decision has to fail in bounded time — otherwise a service that accepts connections and then stops answering holds every authenticated request open for a minute and a half each. | | `BestEffort` | Unbounded — the ambient 100-second HTTP client default. A slow enrichment service still gets to answer, because cutting it off would not refuse anybody, it would admit them with that service’s details silently missing. | A timeout you **do** state is honored in both modes. The wait is also bound to the caller’s own request lifetime either way, so a client that disconnects stops occupying the proxy. Where several services take part, **every** service in `Required` mode must answer with a positive. Requirements add together and are never widened, the same way [service claim requirements](/authproxy/configuration/authorization/) compose. A `BestEffort` service failing alongside them costs the caller nothing but the details it would have supplied. ### On every denial AuthProxy serves the [forbidden page](/authproxy/configuration/well-known-pages/) at `403` and erases everything an earlier success left behind: the sealed `.cratis-identity-authorization` record is cleared, the readable `.cratis-identity` cookie is expired, and the in-memory result is evicted. Without that, the next request would present one of them and skip the question that was just answered no. Set `Cratis:AuthProxy:Session:TerminateOnIdentityDenial` to `true` when a denial should also end the local AuthProxy session. AuthProxy signs out of its authentication cookie and clears the identity, authorization, tenant, invitation, registration, provider-selection, transient authentication, and configured additional logout cookies before serving the same `403`. It retains capability-entry and in-progress logout state, and does not initiate logout at the external identity provider. The default is `false`, which preserves the authenticated session exactly as earlier releases did. Note Two of those three erasures are *requests to the browser*, not guarantees. Clearing a cookie means sending a `Set-Cookie` that expires it, and a non-browser caller is free to ignore it and keep presenting the sealed record it was issued. So what `Required` bounds is **revocation latency**, not reuse: a positive can be replayed for at most `IdentityRevalidationInterval` (the sealed record) or `IdentityResultCacheDuration` (the proxy’s own cache), whichever applies, and no longer. Shorten them to shorten that window — or set `IdentityRevalidationInterval` to zero, which under `Required` means no record is sealed at all and every request is verified. The denial is logged with a bounded reason code (`TransportFailure`, `TimedOut`, `UnsuccessfulStatusCode`, `NoVerdict`, …) and never with the response body, which is content from a system that knows who the caller is. ### Turning the memory off Two settings under [`Session`](/authproxy/configuration/authentication/#session) bound how long an answer is reused: * `IdentityResultCacheDuration` (default 30 seconds) — the proxy’s own in-memory cache, which collapses a page load’s burst of requests into one round-trip per user and tenant. Set it to zero to resolve on every request. * `IdentityRevalidationInterval` (default 10 minutes) — how long the sealed authorization record is honored. Set it to zero for no bound. Under `Required`, “no bound” means **no record is written at all**, so every request is verified. Both are the window in which a user whose access has just been revoked still gets through, so shorten them deliberately: the cost is one identity-endpoint call per request per user. ```json { "Cratis": { "AuthProxy": { "Session": { "IdentityResultCacheDuration": "00:00:05", "IdentityRevalidationInterval": "00:01:00" }, "Services": { "portal": { "Backend": { "BaseUrl": "http://portal-api:8080/" }, "IdentityVerificationTimeout": "00:00:10" }, "reporting": { "Backend": { "BaseUrl": "http://reporting-api:8080/" }, "IdentityVerification": "BestEffort" } } } } } ``` `portal` states no mode, so it is `Required`; `reporting` only enriches and says so. From Aspire, `Required` needs no call. Opt a service out explicitly: ```csharp authProxy.WithIdentityVerification("reporting", IdentityVerificationMode.BestEffort); authProxy.WithSessionTerminationOnIdentityDenial(); ``` > **Requiring verification makes that service a single point of failure, on purpose.** While it is down, nothing behind the proxy is served, because nobody can confirm who is allowed in. That is the trade the default makes — and a backend that does not implement `/.cratis/me` with a verdict must either say `BestEffort` or set `ResolveIdentityDetails` to `false`. ### `Required` needs a tenant resolution Identity is resolved per **user and tenant**, so a verdict is always a verdict about somebody in some tenant. A deployment with no [tenant resolution](/authproxy/configuration/tenancy/) configured resolves no tenant for anybody — so there is nothing to verify against, on every request, for everyone. AuthProxy therefore **refuses to start** when a service is `Required` — which includes a service that states no mode, since `Required` is the default — and `TenantResolutions` is empty, and the message names the key and the two ways out for a service that only enriches. Nothing about the alternative looks broken from the outside: the proxy starts, people sign in, requests are forwarded, and the only thing that does not happen is the check you asked for. A single-tenant deployment satisfies this with the `Specified` strategy: ```json { "Cratis": { "AuthProxy": { "TenantResolutions": [ { "Strategy": "Specified", "Options": { "TenantId": "00000000-0000-0000-0000-000000000000" } } ] } } } ``` At request time the same principle applies: an authenticated caller whose tenant does not resolve is **refused**, not waved through. There is one deliberate exemption — a path listed in [`AnonymousPaths`](#anonymous-paths) is declared to be served without a session at all, so no verdict is demanded for it and the application stays responsible for authorizing it, exactly as that setting already says. AuthProxy’s own sign-in, invite and registration surfaces are also exempt, because they are answered by the proxy and never forwarded to a service; a signed-in caller with no organization has to be able to reach the provider-selection page. *** ## Client credentials When `ClientCredentials` is configured for a service, AuthProxy exposes `POST /.cratis/token`. That endpoint forwards the supplied client credentials to the service’s verification endpoint and, on success, issues a bearer token scoped to the configured `RoutePrefix`, along with a refresh token that can later be exchanged for a new access token without resupplying the client credentials. `RoutePrefix` defaults to `/api` and is checked against the incoming path, before `StripPathPrefix` removes anything. For a service with `PathPrefix: /reporting`, set `ClientCredentials.RoutePrefix` to `/reporting/api` to accept bearer tokens on its API routes (or another explicitly permitted external prefix). Host routing can distinguish services that share the same `RoutePrefix`. This creates a one-to-one relationship between: * the proxied service * the route prefix the token may access * the downstream endpoint that verifies the client credentials The verification endpoint’s response can optionally include a `tenant` property, which AuthProxy then carries on the issued tokens and can resolve into the `x-cratis-tenant-id` header on proxied requests. See [Back-channel client credentials](/authproxy/configuration/authentication/#back-channel-client-credentials) for the full token, tenant-resolution, and refresh-token flow. *** ## Bearer routes A bearer route is a path prefix on a service that is called by programs — a CLI, an MCP client, another service — with an access token issued by an external authorization server such as Cratis Identity. AuthProxy stays a relying party: it validates the token and forwards the request, and it never issues these tokens. ```json { "Cratis": { "AuthProxy": { "Authorization": { "RequiredClaims": [ { "Claim": "urn:github:team", "AnyOf": [ "Cratis/direct" ] } ] }, "Services": { "direct": { "Backend": { "BaseUrl": "http://direct:8080/" }, "Frontend": { "BaseUrl": "http://direct:8080/" }, "BearerRoutes": [ { "PathPrefix": "/mcp", "Issuers": [ { "Issuer": "https://auth.example/" } ], "Audiences": [ "direct-api" ], "RequiredScopes": [ "direct:read" ], "ResourceMetadataUrl": "https://cratis.direct/.well-known/oauth-protected-resource/mcp", "IdentityProvider": "github", "ClaimMappings": { "sub": "github_id", "preferred_username": "github_login" }, "IgnoreDeploymentRequiredClaims": true, "AcceptWithoutIdentityVerification": true }, { "PathPrefix": "/v1", "Issuers": [ { "Issuer": "https://auth.example/" } ], "Audiences": [ "direct-api" ], "ResourceMetadataUrl": "https://cratis.direct/.well-known/oauth-protected-resource/v1", "IdentityProvider": "github", "ClaimMappings": { "sub": "github_id", "preferred_username": "github_login" }, "IgnoreDeploymentRequiredClaims": true, "AcceptWithoutIdentityVerification": true } ] } } } } } ``` This is Direct’s shape: Cratis Identity issues tokens with `aud=direct-api` for both `https://cratis.direct/mcp` and `https://cratis.direct/v1`. The claim mappings make a token-authenticated request carry the same `x-ms-client-principal-id` (the numeric GitHub id) and `x-ms-client-principal-name` (the GitHub login) as a browser session signed in through Direct’s GitHub provider, so Direct resolves the same user either way. The Cratis account id from the token’s `sub` is still forwarded, as the `urn:cratis:bearer:subject` claim. Direct’s deployment also requires the `urn:github:team` claim. A browser session gets it from Direct’s GitHub sign-in, which reads team membership from the GitHub API; a Cratis Identity access token does not carry it. Claim requirements apply to bearer routes by default, so without `IgnoreDeploymentRequiredClaims` every token on these routes would be refused with `403`. With it, the proxy-wide and service requirements are left out on the route, AuthProxy logs a warning at startup naming them, and Direct’s backend is what decides whether the caller is a member of the tenant. When Cratis Identity mints a team or membership claim, either remove `IgnoreDeploymentRequiredClaims` (if the claim is `urn:github:team` itself), or keep it and require the new claim on the route: ```json "RequiredClaims": [ { "Claim": "urn:cratis:membership", "AnyOf": [ "direct" ] } ] ``` `AcceptWithoutIdentityVerification` states the same thing for `/.cratis/me`: Direct answers it for browser sessions, a bearer route never calls it, and Direct’s backend checks the caller’s membership of the tenant on every token-authenticated request instead. ### BearerRouteConfig properties | Property | Type | Default | Description | | --- | --- | --- | --- | | `PathPrefix` | `string` | — | The prefix this route covers, for example `/mcp`. Matched case-insensitively on segment boundaries, with the same rules as [anonymous paths](#what-a-valid-entry-looks-like). The longest matching prefix wins. | | `Issuers` | `BearerIssuerConfig[]` | — | The authorization servers whose tokens are accepted. At least one. | | `Audiences` | `string[]` | — | The token’s `aud` must exactly match at least one of these, including any trailing slash. At least one. | | `RequiredScopes` | `string[]` | `[]` | Scopes the token must carry, every one of them. | | `ResourceMetadataUrl` | `string` | `null` | Absolute URL of the RFC 9728 protected-resource metadata document. Named in every challenge; its path is forwarded to the backend without authentication and must be outside every bearer-route prefix. | | `TenantClaimType` | `string` | `tid` | The token claim the tenant is read from. See [Tenancy](/authproxy/configuration/tenancy/#bearer-routes). | | `ClaimMappings` | `map` | `{}` | Forwarded claim type → token claim it is read from, replacing all case variants of the target. A missing source refuses the token; mapped `sub`, `preferred_username` and `name` require one usable source value. Targets may not differ only by case or overwrite the route’s tenant claim. Claim types containing `:` cannot be keys, because `:` separates configuration sections. | | `IdentityProvider` | `string` | `bearer` | The identity provider named in the forwarded principal. | | `ForwardAuthorizationHeader` | `bool` | `false` | Whether the backend also receives the `Authorization` header. | | `ClockSkew` | `TimeSpan` | `00:00:30` | Allowed clock skew for `exp` and `nbf`. At most `00:05:00`. | | `RequiredClaims` | `{ Claim, AnyOf }[]` | `[]` | Claim requirements of the route’s own, checked against the token’s principal after `ClaimMappings`, in addition to the deployment’s. Same shape and rules as [`Authorization:RequiredClaims`](/authproxy/configuration/authorization/); a requirement on a role claim is refused at startup, because a token never carries a role. | | `IgnoreDeploymentRequiredClaims` | `bool` | `false` | Leave the deployment’s claim requirements — proxy-wide and the service’s — out on this route. For a deployment whose requirements name a claim the token issuer does not mint. Logged as a warning at startup. | | `AcceptWithoutIdentityVerification` | `bool` | `false` | State that this route’s callers are accepted without any service’s `/.cratis/me` being asked about them. Required when a service declares `IdentityVerification: Required`; under `BestEffort` it silences the startup warning. See [What a bearer route changes](#what-a-bearer-route-changes). | ### BearerIssuerConfig properties | Property | Type | Default | Description | | --- | --- | --- | --- | | `Issuer` | `string` | — | The issuer identifier. The token’s `iss` and the issuer metadata’s `issuer` must both be exactly this value. HTTPS, or plain HTTP on a loopback host for development. | | `MetadataAddress` | `string` | RFC 8414 address | The metadata document. Defaults to `/.well-known/oauth-authorization-server` inserted between the issuer’s host and path. An OpenID Connect discovery document works too. | | `TokenTypes` | `string[]` | `at+jwt`, `application/at+jwt` | Accepted JWT `typ` header values, so an ID token cannot be presented as an access token. | ### What a bearer route changes * A request on a bearer route is answered before static files, authentication, provider selection, tenant selection and identity enrichment — none of them apply. [Admission](/authproxy/configuration/admission/) and the trusted-proxy boundary run first and apply as to any request. * The deployment’s [claim requirements](/authproxy/configuration/authorization/) — proxy-wide and the route’s service’s — apply to the token’s principal after `ClaimMappings`, unless the route sets `IgnoreDeploymentRequiredClaims`. The route’s own `RequiredClaims` apply on top either way. A token that does not satisfy them is refused with `403`. A requirement on a role can never be met: roles are dropped from every token. * A bearer route **never calls `/.cratis/me`**, in either identity-verification mode: the endpoint answers for browser sessions, not for principals authenticated by a token. The backend must enforce tenant membership. * Under explicitly configured `BestEffort`, a `403` from a service’s `/.cratis/me` refuses a browser session but not a token. AuthProxy starts, and logs a warning for each bearer route in a deployment where some service answers `/.cratis/me`, unless the route sets `AcceptWithoutIdentityVerification: true`. * Under `Required` (the default), AuthProxy **refuses to start** with a bearer route unless the route sets `AcceptWithoutIdentityVerification: true`. Setting it is the operator’s statement that, on this route, the validated token, its scopes and the claim requirements are the whole decision at the edge and the backend answers for the rest. * Bearer prefixes are full external paths claimed on **every host**, not relative to the owning service’s `PathPrefix` or restricted by its `Hosts`. They may lie under their own service’s `PathPrefix`, but cannot overlap another service’s `PathPrefix`, even on different hosts. AuthProxy refuses such overlaps at startup. * A service with bearer routes cannot set `StripPathPrefix: true`: a browser request such as `/app/api/mcp/tools` could otherwise be stripped to `/api/mcp/tools` and bypass the bearer policy there. AuthProxy refuses this configuration at startup. Set `StripPathPrefix: false` and have the backend serve the full external paths, or remove the bearer routes from that service; do not expose the same bearer-only backend resource through another service that strips a prefix. * An accepted request is forwarded straight to the service **backend**, whichever of the service’s endpoints would otherwise serve that path, with its path and query unchanged. AuthProxy adds no `Service-ID` header; one the caller sent is passed through like any other request header that is not an identity header. The backend’s [activity timeout](#timeouts-and-streaming) applies, with backend → service → root → default precedence, including after configuration reloads. * The session cookie is never read, and the `Cookie` header is not forwarded. * When any bearer route is configured, a request whose path still carries percent-encoding after the server has decoded it (such as an encoded `/`), a backslash, a `;` anywhere in it, repeated `/` separators, or a `.` or `..` segment is refused with `400` **before route selection**, including on browser-session paths. A backend that decoded or normalized it differently could otherwise serve a bearer resource under browser-session authentication or a weaker bearer policy. The `;` starts a path parameter, which Tomcat, Jetty and Spring strip before resolving dot segments, so they read `/mcp/..;/api/items` as `/api/items`. Repeated separators can hide a stricter nested route, such as `/v1//admin`, or the only bearer prefix, such as `/api//mcp/tools` when `/api/mcp` is configured. Clients must send unambiguous paths; there is no browser-session fallback. * Every refusal is an API-style `400`, `401`, `403` or `503` — or `405` for a method other than `GET` or `HEAD` on the `ResourceMetadataUrl` path — never a redirect or a page. See [Bearer routes](/authproxy/configuration/authentication/#bearer-routes-access-tokens-from-an-authorization-server). * A token from a bearer-route issuer is refused with `401` on every path that is **not** one of the issuer’s bearer routes, even where another bearer scheme (the [JWT Bearer](/authproxy/configuration/authentication/#jwt-bearer-api) handler) would accept it. A browser-only surface such as `/api` stays browser-only. * A route that overlaps an anonymous path, repeats another route’s prefix, names no issuer or audience, or belongs to a service without a backend is refused at startup, as is one that does not accept callers without identity verification in a deployment that requires it. * With no bearer route configured, the bearer gate leaves requests untouched. Browser sessions still strip inbound `x-cratis-token-scope` and `x-cratis-token-client-id` headers and claims in the `urn:cratis:bearer:` namespace, which only bearer routes set. *** ## Forwarding the user’s access token By default a backend learns who the user is from the identity headers only. A backend that has to call another API on the user’s behalf, such as Microsoft Graph or a downstream domain API through the on-behalf-of flow, needs a real access token issued for it. With `AccessToken`, AuthProxy works as a backend for frontend (BFF). It obtains an access token for the backend’s audience for the signed-in user and forwards it as `Authorization: Bearer `: ```json { "Cratis": { "AuthProxy": { "Services": { "reporting": { "Backend": { "BaseUrl": "http://reporting-api:8080/" }, "Frontend": { "BaseUrl": "http://reporting-web:3000/" }, "AccessToken": { "Scopes": [ "api://reporting/access_as_user" ] } } }, "Authentication": { "OidcProviders": [ { "Name": "Microsoft", "Authority": "https://login.microsoftonline.com//v2.0", "ClientId": "", "ClientSecret": "", "Scopes": [ "offline_access" ] } ] } } } } ``` The backend then validates an ordinary JWT. Its audience is the backend’s own app registration, so the backend can exchange it for downstream tokens without signing anyone in itself. ### ServiceAccessTokenConfig properties | Property | Type | Description | | --- | --- | --- | | `Scopes` | `string[]` | Scopes to request for the backend’s audience, for example `api://reporting/access_as_user` (Microsoft Entra ID). | | `Resource` | `string` | Optional resource indicator ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)) for identity providers that select the audience with `resource`. | | `Provider` | `string` | Optional OIDC provider name. When set, only users who signed in with that provider get a token. Everyone else is refused. | At least one of `Scopes` or `Resource` is required, the service needs a `Backend`, and at least one OIDC provider must be configured. AuthProxy refuses to start otherwise. ### How the token is obtained * After an OIDC sign-in passes validation, AuthProxy keeps the refresh token the provider issues **server-side** when issuing the session cookie. The cookie carries only an unguessable reference to it, inside its encrypted ticket. In this OIDC flow, the refresh token, access tokens and ID token never reach the browser. Failed sign-ins and identity-link callbacks create no stored token session. * For each request to the backend, AuthProxy uses that refresh token at the provider’s token endpoint (`grant_type=refresh_token`) to get a token for the service’s scopes. The token is cached per session, audience, and selected backend destination, and renewed shortly before it expires. AuthProxy authenticates to the token endpoint with the provider’s `ClientSecret` or [client credential](/authproxy/configuration/authentication/#client-credentials-certificates-and-federated-credentials), and stores a rotated refresh token when the provider issues one. Once a refresh starts, it finishes under a ten-second operation timeout independently of browser cancellation, so navigation does not discard a received rotation. * Request `offline_access` (or your provider’s equivalent) in the provider’s `Scopes`. Without a refresh token AuthProxy cannot get access tokens, and logs a warning at each such sign-in. * Signing out removes the refresh token and every access token kept for the session, even when a refresh is in flight. Signing in again replaces the previous token session. Rotation and new audiences do not extend an absolute session’s original retention deadline. When `Session.SlidingExpiration` is enabled, authenticated cookie activity renews token retention even on frontend or non-forwarding routes. ### What is forwarded, and when it is refused * Only requests that are authenticated by the AuthProxy session and routed to the service’s `Backend` get a token. The token replaces any `Authorization` header the browser sent. * Requests to the `Frontend`, requests on [anonymous paths](#anonymous-paths), and machine callers that authenticate with their own bearer token ([client credentials](#client-credentials) or JWT bearer) are forwarded as before. * Tokens stay bound to the backend and audience selected for the request across configuration reloads. Changing only `Backend.BaseUrl` obtains a token again instead of reusing one cached for the previous backend, even when the session, provider, scopes, and resource are unchanged. Each service also has its own backend destination: services that declare the same provider, scopes, and resource, even with the same `Backend.BaseUrl`, each obtain and cache their own token and their own rejection backoff. If a request captures a token policy and destinations from different configuration versions, AuthProxy refuses it with `503` before obtaining or forwarding a token. Retry after the reload completes. * When no token can be obtained, the request is refused with `401` instead of being forwarded without one. This happens when the session has no refresh token, the provider rejects the refresh token, the provider cannot be reached, or the user signed in with another provider than `Provider`. An `invalid_grant` error refuses that audience without discarding the session or other audiences: it can mean missing consent or a resource-specific policy rather than an expired refresh token. Rejections are cached for 30 seconds per session, audience, and backend destination to avoid repeatedly redeeming the same refused refresh token. Signing in again clears the previous session’s rejections. The frontend should treat the `401` as a signal to sign in again through `/.cratis/login/{scheme}`; a missing consent or policy requirement may also need to be addressed at the provider. ### Running more than one instance Refresh and access tokens are kept in AuthProxy’s memory, encrypted with its [Data Protection keys](/authproxy/configuration/authentication/#data-protection-keys-and-horizontal-scaling). They do not survive a restart and are not shared between replicas. With several replicas, route each session to the same replica (sticky sessions). Otherwise a request that lands on another replica is refused with `401` until the user signs in again. Sessions that began before `AccessToken` was configured hold no refresh token either, so their users sign in again once. # Sign-in Notifications > Have AuthProxy notify your application over a service-to-service back channel each time a user actually signs in through an identity provider. AuthProxy can notify the application every time a user **actually signs in** — that is, when a signed-out user completes an interactive identity-provider login and a fresh session is established. The application can then record the sign-in, for example to alert the user of a new sign-in from an unfamiliar location or device. The notification is a service-to-service back-channel, mirroring the [invite exchange](/authproxy/configuration/lobby/invitation-to-organization/) and the [credential link callback](/authproxy/configuration/link/): AuthProxy `POST`s a small JSON payload to a configured application endpoint. *** ## When it fires The notification fires **only on a genuine logged-out → signed-in transition**: * ✅ A signed-out user is redirected to an identity provider, authenticates, and returns with a fresh ticket. * ❌ An already-authenticated user making ordinary proxied requests — the existing session cookie is reused, no provider round-trip happens, and no notification is sent. * ❌ The [credential-linking](/authproxy/configuration/link/) flow — that authenticates a *second* provider without establishing a new primary session, so it is never reported as a sign-in. Technically, AuthProxy hooks the provider **callback** (`OnTicketReceived`), which the framework raises only when a new authentication ticket is delivered from a provider — not when an existing session is validated. That is what scopes the event to real sign-ins rather than every request. A notification failure never breaks the sign-in: the call is best-effort and any error is logged and swallowed. *** ## The payload AuthProxy posts one of two JSON bodies to the configured [`NotifyUrl`](#configuration). A provider without [canonical federated identity](/authproxy/configuration/authentication/#canonical-federated-identity) keeps the legacy body: ```json { "subject": "", "identityProvider": "", "ipAddress": "", "location": "", "browser": "", "operatingSystem": "", "userAgent": "" } ``` An opted-in canonical provider adds `providerKey` and `issuer`; `subject` is the exact configured canonical subject and `identityProvider` becomes the same compatibility value as `providerKey`: ```json { "subject": "", "providerKey": "", "issuer": "", "identityProvider": "", "ipAddress": "", "location": "", "browser": "", "operatingSystem": "", "userAgent": "" } ``` * **Identity fields** — legacy providers select `subject` and `identityProvider` from the freshly authenticated principal. Canonical providers send the complete `(providerKey, issuer, subject)` tuple. Consumers must bind all three tuple components; neither `subject` alone nor `identityProvider` is a stable cross-provider account key. * **`ipAddress`** — the client IP, taken from the left-most `X-Forwarded-For` entry (falling back to the connection’s remote address). * **`location`** — a best-effort, coarse location. See [Approximate location](#approximate-location) below. * **`browser`** / **`operatingSystem`** — parsed from the `User-Agent` header with a lightweight built-in heuristic (no third-party user-agent database). Unrecognized values are sent as empty strings rather than guessed. * **`userAgent`** — the raw header, so the application can do its own richer parsing if it wants to. By default the notification carries **no credential** and relies on the endpoint being network-isolated (see [Security](#security)). Configure [the signed envelope](#the-signed-envelope) to authenticate it instead. Canonical identity is opt-in per provider, so an application migrating provider registrations must accept both body shapes. A notification says that a provider authenticated the tuple; it does not grant application membership, roles, scopes, or authorization. *** ## Approximate location AuthProxy deliberately does **not** bundle a geo-IP database — that would be a heavy dependency and a data pipeline of its own. The `location` is instead derived from what is already on the request: * the resolved client IP (always sent); and * coarse geo headers that a fronting CDN or reverse proxy may add — Cloudflare’s `CF-IPCountry`, and the conventional `X-Geo-City` / `X-Geo-Region` / `X-Geo-Country` and `X-AppEngine-City` / `-Region` / `-Country` headers. When those headers are present, `location` is assembled as `City, Region, Country`. When they are **not** present, `location` is empty and only the IP travels — the application can resolve a fuller location from the IP itself if it needs one. This keeps AuthProxy dependency-light while still recording a genuine approximate location wherever the infrastructure provides one. > **Note.** The client IP and any derived location are personal data. Handle and retain them in the application accordingly. *** ## Configuration Set the application endpoint that records a completed sign-in under `Cratis:AuthProxy:SignIn:NotifyUrl`. ```json { "Cratis": { "AuthProxy": { "SignIn": { "NotifyUrl": "https://studio.example.com/api/internal/sign-ins" } } } } ``` Equivalent environment variable: ```dotenv Cratis__AuthProxy__SignIn__NotifyUrl=https://studio.example.com/api/internal/sign-ins ``` When `NotifyUrl` is empty or the `SignIn` section is absent, sign-in notifications are disabled (nothing is posted). ### Running the handshake on one host The same `SignIn` section holds `CallbackHost`, which has nothing to do with notifications. It names the one host the identity-provider handshake runs on, so a deployment that serves several hosts registers a single callback and a single post-logout address with the provider: ```json { "Cratis": { "AuthProxy": { "Session": { "CookieDomain": ".example.com" }, "SignIn": { "CallbackHost": "auth.example.com" } } } } ``` | Property | Default | Description | | --- | --- | --- | | `CallbackHost` | `""` | Host, with an optional port, that runs every OAuth or OIDC handshake. Empty keeps today’s behavior: each host completes its own. It requires `Session:CookieDomain`, and the host has to lie inside it; AuthProxy refuses to start otherwise. | A sign-in or sign-out that starts on another host is sent to the callback host and returns to the original address, which is only honored for hosts a service declares, frontends, the lobby and [`Logout:AllowedRedirectOrigins`](/authproxy/configuration/logout/#allowing-additional-post-logout-origins), inside the cookie domain. Invitations, credential links and registration keep running on the host they start on. See [One proxy, several hosts](/authproxy/configuration/one-proxy-several-hosts/#4-run-every-handshake-on-one-host). *** ## The signed envelope Without further configuration the notification body is the *only* evidence the application has, so anything that can reach the endpoint chooses which user gets recorded as signed in — including the `subject`, `providerKey` and `issuer`. Set `Cratis:AuthProxy:SignIn:Attestation` and AuthProxy signs a short-lived RS256 JWS over each notification and sends it as `Authorization: Bearer`. **The body is unchanged.** The envelope travels in a header, so an application already consuming notifications keeps parsing exactly the same JSON. ### What the envelope binds The envelope is a profile of [RFC 9449 (DPoP)](https://www.rfc-editor.org/rfc/rfc9449) rather than a scheme of its own. Six facts are bound: | Fact | Carried by | Meaning | | --- | --- | --- | | Provenance | `iss` + the `kid` JWS header | which AuthProxy deployment signed it, and under which key | | Audience | `aud` | the single application entitled to consume it | | Route | `htm`, `htu` | the method and target URI of the request it accompanies | | Body | `body_hash` | base64url SHA-256 of the exact bytes posted | | Time | `iat`, `nbf`, `exp` | the window it is valid in | | Replay | `jti` | a random 256-bit identifier, unique per notification | A `purpose` claim of `sign-in-notification` separates the envelope from every other assertion AuthProxy signs, so an invitation attestation can never be presented in its place. Two details a verifier must implement exactly: * **`htu` follows RFC 9449** — the target URI *without* query and fragment. Compare it against the query-stripped request URI, not the raw target. Because the query is deliberately outside the binding, a `NotifyUrl` that carries one would be signed without it — so AuthProxy refuses to start with a query on `NotifyUrl` once `Attestation` is configured. Put anything the application needs in the body instead. * **`body_hash` is an AuthProxy extension** — RFC 9449 defines no body digest. It uses the identical construction to that specification’s `ath` claim: unpadded base64url of the SHA-256 of the raw request body. ### Verifying a notification 1. Reject the request outright if the `Authorization: Bearer` header is missing. 2. Select the public key by the JWS `kid` header from your pinned key set, and require RS256. 3. Validate `iss`, `aud`, `exp` and `nbf` with no clock skew allowance beyond your own tolerance. 4. Require `purpose` to be `sign-in-notification`. 5. Compare `htm` to the request method and `htu` to the request URI with query and fragment removed. 6. Read the raw request body **before** deserializing it, and compare `body_hash` to its SHA-256 digest. 7. Reject a `jti` already seen inside the envelope lifetime. > **AuthProxy publishes no JWKS document.** The verifying application pins the public keys by its own configuration and selects one by `kid` — the same way the invitation authority consumes invitation attestations. Key rotation is therefore a coordinated configuration change on both sides. ### Configuring it ```json { "Cratis": { "AuthProxy": { "SignIn": { "NotifyUrl": "https://studio.example.com/api/internal/sign-ins", "Attestation": { "Issuer": "https://auth.example.com", "Audience": "studio", "ActiveKeyId": "sign-in-2026-08", "Lifetime": "00:00:60", "SigningKeys": [ { "KeyId": "sign-in-2026-08", "PrivateKeyPem": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" } ] } } } } } ``` | Setting | Meaning | | --- | --- | | `Cratis:AuthProxy:SignIn:Attestation:Issuer` | written to `iss`; required, and required to match at the verifier | | `Cratis:AuthProxy:SignIn:Attestation:Audience` | written to `aud`; names the one application entitled to the notification | | `Cratis:AuthProxy:SignIn:Attestation:ActiveKeyId` | the key new envelopes are signed with; must name exactly one configured key | | `Cratis:AuthProxy:SignIn:Attestation:SigningKeys` | the available keys, each a `KeyId` and a PEM-encoded RSA `PrivateKeyPem` of at least 2048 bits; every `KeyId` must be unique | | `Cratis:AuthProxy:SignIn:Attestation:Lifetime` | the envelope lifetime; between 10 and 60 seconds, defaulting to 60 | Supply `PrivateKeyPem` through a secret provider. AuthProxy never returns or logs it — publish only the matching public key to the application. **Key rotation.** Add the new key to `SigningKeys`, publish its public half to the application, then move `ActiveKeyId` to it. Keep the previous key configured until every envelope it signed has expired. ### Compatibility and failure behavior * **Leaving the section unset changes nothing.** No `Authorization` header is added and the body is byte-for-byte what it has always been. * **Once configured, AuthProxy never downgrades.** If an envelope cannot be signed — unusable key material, an `ActiveKeyId` naming no key — the notification is *not posted at all* and the failure is logged. A sign-in is never recorded on unauthenticated evidence. * **Configuration is validated at startup**, so an unusable key fails the process rather than silently suppressing every sign-in notification. When attestation is configured, `NotifyUrl` must also be an absolute HTTPS URL (HTTP is accepted only for loopback development). *** ## Security **Unsigned by default.** With no [`Attestation`](#the-signed-envelope) section the notification JSON is not signed and carries no credential. Point `NotifyUrl` at an internal application address that is **network-isolated** from public traffic, or authenticate AuthProxy separately at the application endpoint. **Signed when configured.** The envelope establishes that AuthProxy produced this exact notification, for this application, over this exact body, recently, and only once. Network isolation and an authenticated envelope are complementary — enabling one is not a reason to relax the other. Either way, treat the identity tuple as authenticated provider metadata, then apply the application’s own authorization policy before changing any access or membership. A verified envelope proves the notification’s origin and integrity; it grants no membership, role, or scope. # Tenancy > AuthProxy resolves a tenant ID from each request and forwards it to downstream services, and prevents clients from choosing their own tenant. AuthProxy resolves a **tenant ID** string from each incoming request and stores it in the request context. Downstream services receive the resolved tenant ID under both `x-cratis-tenant-id` and `Tenant-ID`. [Arc](/arc/) resolves its tenant from `x-cratis-tenant-id` by default, so an Arc application behind AuthProxy needs no tenancy configuration. Legacy `Tenant-ID` forwarding is deprecated and will be removed in a future major release; move backends to `x-cratis-tenant-id`. A client cannot choose the tenant a backend sees. AuthProxy strips an inbound `x-cratis-tenant-id` and the legacy `Tenant-ID` header, and writes back only the tenant it resolved itself under both names. Earlier releases sent only `Tenant-ID`; see [Upgrading](/authproxy/upgrading/tenant-and-service-headers/). *** ## Tenant resolution strategies Resolution strategies run **in order** until one strategy resolves a tenant. Configure them under `Cratis:AuthProxy:TenantResolutions`: ```json { "Cratis": { "AuthProxy": { "TenantResolutions": [ { "Strategy": "Host" }, { "Strategy": "Claim", "Options": { "ClaimType": "tid" } } ] } } } ``` ### Available strategies | Strategy | Description | | --- | --- | | `Host` | Uses the request host and matches it against configured tenant `Domains` / `SourceIdentifiers`. | | `Claim` | Reads a claim value from the authenticated user and matches it against configured tenant `SourceIdentifiers`. | | `Route` | Extracts a source identifier from the request path by regex and matches it against configured tenant `SourceIdentifiers`. | | `Specified` | Resolves directly to a fixed tenant ID string from configuration. | | `Default` | Resolves directly to a fallback tenant ID string from configuration. | | `SubHost` | Resolves directly from subhost convention, for example `acme.example.com` -> `acme`. | | `Selection` | Resolves from the selected-tenant cookie set by the tenant-selection page flow. | *** ## Claim strategy options ```json { "Strategy": "Claim", "Options": { "ClaimType": "tid" } } ``` If `ClaimType` is omitted, AuthProxy falls back to reading `X-MS-CLIENT-PRINCIPAL`. This is also how you resolve a tenant carried on an AuthProxy-issued client-credentials bearer token — point `ClaimType` at `cratis/tenant`. See [Resolving a tenant from the verification response](/authproxy/configuration/authentication/#resolving-a-tenant-from-the-verification-response). *** ## Route strategy options ```json { "Strategy": "Route", "Options": { "Pattern": "^/tenant/(?[^/]+)" } } ``` The regex must expose a named group called `sourceIdentifier`. *** ## Specified strategy options ```json { "Strategy": "Specified", "Options": { "TenantId": "acme" } } ``` *** ## Default strategy options ```json { "Strategy": "Default", "Options": { "TenantId": "lobby" } } ``` *** ## SubHost strategy options ```json { "Strategy": "SubHost", "Options": { "ParentHost": "example.com", "VerificationUrlTemplate": "https://platform.example.com/internal/tenants/{tenantId}" } } ``` | Property | Type | Description | | --- | --- | --- | | `ParentHost` | `string` | Parent host suffix used to extract the tenant ID from the request host. | | `VerificationUrlTemplate` | `string` | Optional strategy-specific verification URL template. Overrides the global `TenantVerification.UrlTemplate` for this strategy. | ### How SubHost resolution works The strategy strips the configured `ParentHost` suffix from the incoming request host to derive the tenant ID. Given `ParentHost: "example.com"`: | Request host | Resolved tenant ID | Notes | | --- | --- | --- | | `acme.example.com` | `acme` | Single-segment subhost — resolved successfully. | | `contoso.example.com` | `contoso` | Single-segment subhost — resolved successfully. | | `foo.bar.example.com` | — | Multi-segment subhost rejected — not resolved. | | `example.com` | — | No subhost present — not resolved. | | `other.com` | — | Host does not end with `.example.com` — not resolved. | The resolved subhost string becomes the tenant ID directly. **No `Tenants` dictionary lookup is performed** — unlike `Host`, `Claim`, and `Route` strategies, SubHost does not match a source identifier against a pre-configured list. This is intentional: SubHost is designed for environments where tenants are provisioned dynamically (for example SaaS platforms where each customer gets their own subdomain). ### SubHost and a shared session cookie `Session:CookieDomain` cannot be combined with a `SubHost` resolution: AuthProxy refuses to start. The host-only session cookie is what keeps a session for one tenant off the next tenant’s host, and a cookie shared across the domain would send it to every tenant. See [Why the cookie domain is refused with SubHost tenancy](/authproxy/configuration/one-proxy-several-hosts/#why-the-cookie-domain-is-refused-with-subhost-tenancy). ### SubHost and authentication callbacks For interactive authentication flows, AuthProxy stores SubHost strategy metadata in protected authentication `state` when it issues the provider challenge. On callback, AuthProxy restores this metadata and redirects to the tenant subhost. This enables a shared callback endpoint (for example `auth.cratis.studio`) while returning the user to the original tenant host (for example `nova.cratis.studio`). Because there is no registry lookup to prove the tenant exists, you should configure `VerificationUrlTemplate` to have AuthProxy call your back-end to confirm the tenant is valid before forwarding the request: ```json { "Strategy": "SubHost", "Options": { "ParentHost": "example.com", "VerificationUrlTemplate": "https://internal-api.example.com/tenants/{tenantId}" } } ``` AuthProxy replaces `{tenantId}` with the resolved subhost value and expects a `200` response. Any other response causes the request to be rejected with `tenant-not-found.html`. See [Tenant verification](#tenant-verification) for full response handling details. *** ## Selection strategy options ```json { "Strategy": "Selection", "Options": { "TenantsEndpoint": "https://platform.example.com/api/tenants/selectable" } } ``` | Property | Type | Description | | --- | --- | --- | | `TenantsEndpoint` | `string` | Absolute URL for the endpoint that returns selectable tenants for the current authenticated user. Expected response shape is an array of `{ "id": "...", "name": "..." }` objects. | When this strategy is configured and no `.cratis-tenant` cookie exists yet, AuthProxy calls `TenantsEndpoint`, sets the `.cratis-tenants` cookie, and serves `select-tenant.html`. The page links back to `/.cratis/select-tenant?tenantId=&returnUrl=`, and AuthProxy validates the selected tenant against the endpoint response before writing the `.cratis-tenant` cookie. If `TenantsEndpoint` is unreachable, times out, returns an error other than `401`/`403`, or returns unparseable JSON, AuthProxy responds with `503 Service Unavailable` instead of reporting that the user has no organization. Document navigations and tenant-selection callbacks receive `service-unavailable.html`; other callers receive a bare `503` without an HTML page. An authoritative empty list or `401`/`403` response still proceeds to the normal no-tenant handling (`no-organization.html` at `403` when no lobby is configured). If the signed-in session no longer has a forwardable identity, AuthProxy signs the user out instead of reporting a service outage. Navigations redirect to provider selection with `reason=invalid-session`; other callers receive `401`. Revalidation of an already-selected tenant fails open during an endpoint outage, so a transient failure does not revoke an existing tenant selection. *** ## Tenant registry For `Host`, `Claim`, and `Route`, AuthProxy resolves a source identifier and then looks up the tenant ID in `Cratis:AuthProxy:Tenants`. ```json { "Cratis": { "AuthProxy": { "Tenants": { "acme": { "Domains": ["acme.example.com"], "SourceIdentifiers": ["acme", "tenant-acme"] }, "contoso": { "Domains": ["contoso.example.com"], "SourceIdentifiers": ["contoso", "tenant-contoso"] } } } } } ``` *** ## Lobby fallback When no tenant can be resolved and the [lobby](/authproxy/configuration/lobby/) is configured, AuthProxy redirects the user to the lobby frontend instead of returning `401 Unauthorized`. If no lobby is configured and `TenantResolutions` is non-empty, AuthProxy returns `401 Unauthorized`. When `TenantResolutions` is empty, the request proceeds without a tenant. *** ## Tenant verification After tenant resolution, AuthProxy can verify that the tenant exists before forwarding the request. This is optional. ### Global verification configuration ```json { "Cratis": { "AuthProxy": { "TenantVerification": { "UrlTemplate": "https://platform.example.com/api/tenants/{tenantId}" } } } } ``` | Property | Type | Description | | --- | --- | --- | | `UrlTemplate` | `string` | URL template for tenant verification. Use `{tenantId}` placeholder. | ### Response handling * `200`: tenant exists, request proceeds. * `404`: tenant does not exist; AuthProxy serves `tenant-not-found.html` with `404`. * Any other status or network error: treated as tenant verification failure, and `tenant-not-found.html` is served. If a strategy provides a strategy-specific verification URL template (for example `SubHost.Options.VerificationUrlTemplate`), that template is used instead of the global `TenantVerification.UrlTemplate`. ### Tenant not found page When verification fails, AuthProxy serves `tenant-not-found.html`. See [Error pages](/authproxy/configuration/error-pages/) to override this page. *** ## Bearer routes On a [bearer route](/authproxy/configuration/services/#bearer-routes) the tenant comes from the access token alone: the claim named by `TenantClaimType` (`tid` by default). None of the resolution strategies above run, the tenant-selection cookie is never read and the tenant-selection page is never served. * A token that does not carry exactly one tenant, or carries one that is not made of letters, digits, `-`, `.`, `_` or `~` (at most 256 characters), is refused with `403`. There is no fallback to a default tenant. * When [tenant verification](#tenant-verification) is configured, the tenant is verified the same way; a tenant that fails verification is refused with `403` rather than the tenant-not-found page. * The tenant is forwarded as `x-cratis-tenant-id`, with `Tenant-ID` as a deprecated copy. It records which tenant the user chose when the token was granted; the backend still decides whether the user is a member of it. # Tenant Selection Page > Configure the AuthProxy tenant-selection page that users see after login until they choose a tenant, and how it receives tenant data. When tenant selection is configured, AuthProxy serves a tenant-selection page after login until the user chooses a tenant. The page receives tenant data through a cookie, not by calling your tenant endpoint directly. *** ## Flow 1. Authenticated request arrives without a `.cratis-tenant` cookie. 2. AuthProxy calls the configured `Selection.Options.TenantsEndpoint`. If the endpoint is unavailable or returns an unusable answer, AuthProxy returns `503` with `service-unavailable.html` for a browser navigation (a bare `503` for other callers); no tenant cookie is written. 3. If exactly one tenant is returned, AuthProxy sets `.cratis-tenant` immediately, removes any `.cratis-tenants` cookie, and redirects to the original URL (no selection page shown). 4. If more than one tenant is returned, AuthProxy writes `.cratis-tenants` (URL-encoded JSON array) as a session cookie and serves `select-tenant.html` — but only to a browser navigating to a page. Every other caller is refused with `403` instead, because a chooser page delivered as `200` reads as the data they asked for. See [Unauthenticated responses](/authproxy/configuration/unauthenticated-responses/). 5. User clicks a tenant option. 6. Browser navigates to `/.cratis/select-tenant?tenantId=&returnUrl=`. 7. AuthProxy validates the selected `tenantId` against `TenantsEndpoint` and sets `.cratis-tenant`. If the endpoint is unavailable or returns an unusable answer, AuthProxy returns `503` with `service-unavailable.html` instead; no tenant cookie is written. 8. AuthProxy redirects back to `returnUrl`. The `.cratis-tenants` cookie is **retained** so the application can offer an in-app tenant switcher. *** ## Switching tenants after selection For a user with **more than one** tenant, `.cratis-tenants` is written as a **session cookie** and is **not** deleted when a tenant is selected. It therefore remains available for the rest of the browser session, which lets the application’s toolbar decide whether to show a “switch tenant” control (show it only when the cookie lists more than one tenant). To switch, the toolbar navigates to the same selection endpoint used by the selection page: ```text /.cratis/select-tenant?tenantId=&returnUrl= ``` Every switch re-validates the requested `tenantId` against `TenantsEndpoint`, so a stale cookie can never grant access to a tenant the user is no longer a member of — an unknown `tenantId` is rejected with `400 Bad Request`. *** ## Periodic re-validation of the selected tenant `.cratis-tenant` is a session cookie, but it is also **not trusted indefinitely within a session**: AuthProxy re-validates the selected tenant against `TenantsEndpoint` when the configured `Cratis:AuthProxy:Session:TenantRevalidationInterval` (default 10 minutes) has lapsed. A successful re-validation is cached in memory, so the endpoint is **not** called on every request. When the endpoint answers authoritatively that the tenant is no longer available to the user, the `.cratis-tenant` and `.cratis-tenants` cookies are deleted and the request is replayed without them — the user lands back in the regular selection flow (or the no-tenant handling when nothing remains). Revoked tenant access therefore takes effect within the interval, without waiting for the browser session to end. Transport failures fail open so a transient backend outage cannot lock users out; the next lapse of the window retries. A user with exactly **one** tenant never receives `.cratis-tenants` (it is removed when the single tenant is auto-selected), so no switcher is shown for single-tenant users. *** ## Endpoint response shape `TenantsEndpoint` must return JSON with one object per selectable tenant: ```json [ { "id": "some string", "name": "some string" } ] ``` *** ## Cookie shape used by the page AuthProxy writes `.cratis-tenants` with the same shape: ```json [ { "id": "studio", "name": "Cratis Studio" }, { "id": "sales", "name": "Sales Portal" } ] ``` *** ## Building a custom `select-tenant.html` `select-tenant.html` is a normal overridable page in `PagesPath`, exactly like the other built-in pages. Minimum requirements: 1. Read `.cratis-tenants` from `document.cookie`. 2. Parse JSON and render `name`. 3. Link each item to `/.cratis/select-tenant?tenantId=&returnUrl=`. Example: ```html Select tenant

Select tenant

    ``` # Trusted Proxies > Configure which callers AuthProxy trusts to set X-Forwarded-For and X-Forwarded-Proto, so forwarded headers from untrusted callers are ignored. AuthProxy almost never sees a browser. It sees an ingress controller, a load balancer, a service-mesh sidecar, or a CDN edge — and everything it knows about the person on the other side comes from what that thing wrote into `X-Forwarded-For` and `X-Forwarded-Proto`. Those are ordinary request headers. Nothing about them is signed, and nothing about them is special: any caller that can open a connection to AuthProxy can send whatever it likes in them. The question a reverse proxy has to answer, before it believes a single one, is **which callers are allowed to speak for someone else** — and that is a fact about your network that only your deployment knows. This page is how you tell it. *** ## What is at stake It is tempting to file forwarded headers under “logging detail”. They are not. Two values decide a surprising amount of AuthProxy’s behavior: | Value | What it decides | | --- | --- | | The client address | The `ipAddress` in every [sign-in notification](/authproxy/configuration/sign-in/) — the record your application shows a user as “a new sign-in from 203.0.113.7” and acts on when it looks unfamiliar. | | The request scheme | Whether every AuthProxy session cookie carries `Secure`; what the OIDC `post_logout_redirect_uri` claims your public origin to be; and which origins the [post-logout allow-list](/authproxy/configuration/logout/) admits. | A caller who can set the first one writes its own audit trail. A caller who can set the second one changes whether a browser is willing to send your session cookies at all — and, in the other direction, whether a genuinely encrypted session is protected as one. *** ## Declaring the boundary Name the peers directly in front of AuthProxy: ```json { "Cratis": { "AuthProxy": { "Ingress": { "TrustedProxies": [ "10.0.0.0/8", "203.0.113.7" ], "ForwardLimit": 1 } } } } ``` Or, as environment variables in a container: ```bash Cratis__AuthProxy__Ingress__TrustedProxies__0=10.0.0.0/8 Cratis__AuthProxy__Ingress__TrustedProxies__1=203.0.113.7 Cratis__AuthProxy__Ingress__ForwardLimit=1 ``` Each entry is an IP address (`10.0.0.7`, `2001:db8::1`) or a CIDR range (`10.0.0.0/8`, `2001:db8::/32`). A range written against a host address inside it — `10.0.0.1/8` — means the range, the same as everywhere else. An entry AuthProxy cannot parse **fails startup and names the offending value**; it is never quietly dropped, because a trusted proxy that is silently not trusted is a boundary silently in the wrong place. The addresses to declare are the ones **AuthProxy sees as the peer**, not the addresses of your users. In Kubernetes that is the ingress controller’s pod CIDR; behind a cloud load balancer it is the balancer’s subnet; behind a CDN it is the CDN’s published egress ranges. *** ## `ForwardLimit` decides which address is reported as the client This is the part worth reading twice, because it is the setting most often left at whatever the sample had. `X-Forwarded-For` grows left to right: the outermost proxy appends the address it accepted the connection from, then the next one appends, and so on. AuthProxy therefore reads it **from the right**, consuming one entry per hop, and `ForwardLimit` is how many hops it consumes. Whatever it lands on becomes the client address — the one recorded against a sign-in. Consider a request that reaches AuthProxy carrying `X-Forwarded-For: 198.51.100.9, 203.0.113.30` from a peer at `203.0.113.10`: | `ForwardLimit` | Reported client address | What that means | | --- | --- | --- | | `1` (the default) | `203.0.113.30` | One hop consumed. Correct when a single ingress sits in front — but if there are really two, you are recording **your own inner proxy** as the client, identically for every user. | | `2` | `198.51.100.9` | Two hops consumed. Correct when a CDN sits in front of a load balancer. | | `3` | `198.51.100.9` | The chain ran out first. The surplus can never be reached, which is exactly the protection. | So set it to the number of hops your deployment actually has: * **Too low** and the reported address is your own infrastructure — the audit trail is real but useless. * **Too high** and the reported address is whatever the outermost caller chose to write — the audit trail is attacker-controlled, which is worse than useless. Every hop counted must itself be a trusted peer. AuthProxy re-checks at each step, so raising `ForwardLimit` without also declaring the intermediate addresses in `TrustedProxies` changes nothing. Note **If you have more than one hop and have never set `ForwardLimit`, the address recorded against a sign-in changes with this release.** Read this even if you are not planning to configure anything. AuthProxy used to record the **left-most** entry of `X-Forwarded-For` for the sign-in notification, while everything else in the proxy used the address the forwarded-headers middleware had settled on. Those are two different answers to one question, and the left-most one is the entry furthest from you — the one the outermost caller wrote, which nothing verified. Both now come from the same place, the address the middleware settled on after consuming `ForwardLimit` hops. With `X-Forwarded-For: 198.51.100.7, 203.0.113.9` and the default `ForwardLimit` of `1`: | | Recorded `ipAddress` | What it is | | --- | --- | --- | | Before | `198.51.100.7` | the browser — but taken on trust from the header | | Now | `203.0.113.9` | your own inner load balancer — one hop consumed | Nothing is broken and nothing is less safe; the new value is the honest one for a `ForwardLimit` of `1`. But every sign-in notification and audit record silently changes meaning, and if two hops really do sit in front of you the recorded address becomes identical for every user, which is useless as an audit trail. **To restore the browser’s address, count the hops you actually have.** For the chain above that is `ForwardLimit: 2`, with the inner balancer’s address declared in `TrustedProxies` so the second hop is allowed to be consumed: ```json { "Cratis": { "AuthProxy": { "Ingress": { "TrustedProxies": [ "203.0.113.9", "10.0.0.0/8" ], "ForwardLimit": 2 } } } } ``` Verify it the way [Checking your work](#checking-your-work) describes: sign in and look at the `ipAddress` your `SignIn:NotifyUrl` endpoint received. *** ## Modes `Mode` decides how the trusted set is arrived at. `Configured` is the default and is what almost every deployment wants. ```json { "Cratis": { "AuthProxy": { "Ingress": { "Mode": "LoopbackOnly" } } } } ``` | Mode | Trusts | Use it when | | --- | --- | --- | | `Configured` | Exactly the peers in `TrustedProxies` | Normal deployments. This is the default. | | `LoopbackOnly` | Only a caller on the loopback interface | A sidecar, or local development. A deployment behind an ingress never sees loopback as the peer, so this refuses every forwarded header it receives. | | `TrustAny` | Every caller | Nothing but your own ingress can reach AuthProxy at all — a private network with no other route in, or a listener with no peer address at all (see below). | Caution **Do not set `ASPNETCORE_FORWARDEDHEADERS_ENABLED`.** It is standard advice for containerized ASP.NET images, and it is the wrong thing here: it makes the host insert a forwarded-headers middleware of its own, ahead of every AuthProxy middleware, and clear the known-proxy lists while doing it. The peer AuthProxy records as the caller is then the one the header has already replaced, so the boundary you declared is applied to a request that was rewritten before AuthProxy saw it. AuthProxy consumes forwarded headers itself, from the configuration on this page — leave the variable unset. It logs a warning at startup if it finds it set. ### A listener with no peer address `Configured` and `LoopbackOnly` both decide by looking at the peer’s IP address. A Unix domain socket does not have one — `RemoteIpAddress` is null — so every request over such a listener is treated as coming from an untrusted peer. Sessions keep working, which is what makes this quiet: the visible symptom is that `X-Forwarded-Proto` stops being honored, so cookies lose `Secure` and the public origin reverts to the transport scheme. If AuthProxy listens on a Unix socket, and the only thing that can write to that socket is your own ingress, say `TrustAny`. That is what “the peer is trustworthy and cannot be identified by address” looks like when it is stated rather than stumbled into. *** ## The compatibility fallback, and the warning `Configured` with an **empty** `TrustedProxies` keeps the behavior AuthProxy has always had: every caller’s forwarded headers are believed. Upgrading breaks nothing. It is not, however, silent. At startup AuthProxy logs a warning naming the mode it is running in and the configuration key that leaves it: ```text warn: AuthProxy is running in Configured trusted-proxy mode with no trusted proxies configured, so it believes the X-Forwarded-For and X-Forwarded-Proto headers of every caller. Set Cratis:AuthProxy:Ingress:TrustedProxies to the addresses or CIDR ranges of the ingress in front of it, or set Cratis:AuthProxy:Ingress:Mode to LoopbackOnly or TrustAny to state the choice explicitly. A future major release will refuse to start in this state. ``` Note Treat the warning as a task, not as noise. A future major release turns this condition into a refusal to start. Declaring `TrustedProxies` — or stating `TrustAny` deliberately, if that genuinely describes your network — is what clears it. If you truly do run somewhere nothing else can reach AuthProxy, say `TrustAny` rather than leaving the list empty. The behavior is identical; the difference is that one of them is a decision somebody made and the other is an omission nobody noticed. *** ## What a declared boundary changes Once `TrustedProxies` is set, a request from a peer outside it is treated as what it is — a caller talking directly to AuthProxy: * `X-Forwarded-For` is ignored; the connection’s real address is the client address. * `X-Forwarded-Proto` is ignored; the real transport scheme decides cookie `Secure` and the public origin. * The geo headers a fronting CDN adds — `CF-IPCountry`, `CF-Region`, `CF-IPCity`, and the conventional `X-Geo-*` / `X-AppEngine-*` city, region and country headers — resolve to an empty `location` in the sign-in notification, because from an untrusted caller they are values it chose rather than facts about where it is. A request from a peer **inside** the boundary is honored exactly as before, for as many hops as `ForwardLimit` allows. Two headers are never honored, from any peer: `X-Forwarded-Host` and `X-Forwarded-Prefix`. AuthProxy consumes only the address and the scheme, so neither the request host nor the path base can be moved by a header. The RFC 7239 `Forwarded` header is likewise not consumed. *** ## Configuring it from Aspire The [Aspire hosting integration](/authproxy/aspire/) writes the same configuration keys: ```csharp var authProxy = builder.AddAuthProxy("authproxy") .WithTrustedProxies("10.0.0.0/8", "203.0.113.7") .WithForwardLimit(2); ``` `WithTrustedProxies` appends, so the peers can be declared wherever each one is known. An entry that is neither an address nor a CIDR range is refused when the app host builds, rather than being carried to the proxy and refused there. *** ## Checking your work The address AuthProxy settled on is the one it reports in the sign-in notification, so the quickest end-to-end check is to sign in and look at what your `SignIn:NotifyUrl` endpoint received: * `ipAddress` should be the address of the machine you signed in from — not an address inside your cluster. * `location` should be populated if, and only if, a fronting CDN is adding geo headers. If `ipAddress` is one of your own proxies, raise `ForwardLimit` by one and add that proxy’s address to `TrustedProxies`. If it is an address you can influence from outside, `ForwardLimit` is too high. # Unauthenticated Responses > How AuthProxy answers a caller that cannot proceed, by serving a page or returning a status code depending on the caller rather than the path. AuthProxy answers a caller that cannot proceed in one of two ways: it serves a page, or it returns a status code. Which one it picks depends on the caller, not on the path. > **Not on a closed deployment.** Everything on this page describes a deployment in the default `Public` admission mode. Under [`CapabilityOnly`](/authproxy/configuration/admission/) none of it applies to a caller who has not been admitted: there is no page, no `401`, no `403` and no redirect — every route and every method answers one byte-identical `404`, and the rules below start applying only once a capability has been presented. *** ## Why the caller decides The provider-selection and tenant-selection pages are HTML, and HTML has to be delivered with a success status to render. That is the right answer to a person in a browser, and the wrong answer to everything else: * A webhook, an e-signing callback or any server-to-server integration reads **HTTP 200** as *delivered*. It does not retry, nothing is queued for redelivery, and nothing errors — so a refused request looks exactly like a successful one. * A frontend’s `fetch()` passes the conventional `response.ok` check and only fails later, when parsing HTML as JSON. Arc’s own identity bootstrap calls `/.cratis/me` on boot and hits precisely this. So AuthProxy serves a page only to a caller that is navigating to one, and refuses everyone else with a status code they can act on. *** ## The rule | Caller | Response | | --- | --- | | A browser navigating to a document | The page — provider selection at `200`, or a redirect to the identity provider | | Anything else | `401 Unauthorized`, or `403 Forbidden` when the caller is already authenticated | A caller is treated as navigating when either: 1. It sends `Sec-Fetch-Dest: document` (or `iframe` / `frame`). Every current browser sends this header on every request, and it is the only signal that separates a navigation from a `fetch()` issued by the *same* browser on the *same* connection. 2. It sends no `Sec-Fetch-Dest` at all — a client predating fetch metadata — **and** names `text/html` explicitly in `Accept`. A wildcard `Accept: */*` does not count. That is what `fetch()`, `curl` and most webhook senders send, and reading it as “HTML will do” is what produced the `200` in the first place. A caller that states nothing at all is treated as an API caller, because it almost always is. *** ## What returns what | Situation | Browser navigation | Other callers | | --- | --- | --- | | Unauthenticated, multiple providers configured | `200` + `select-provider.html` | `401`, no page, no `.cratis-providers` cookie | | Unauthenticated, one provider configured | `302` to the provider | `401` | | Unauthenticated, no providers configured | Forwarded | Forwarded | | Authenticated, tenant selection required | `200` + `select-tenant.html` | `403`, no page, no `.cratis-tenants` cookie | | Authenticated, `TenantsEndpoint` unavailable | `503` + `service-unavailable.html` | `503`, no page (the `/.cratis/select-tenant` callback always serves `service-unavailable.html`) | | Lobby mode, no invitation | `401` + `invitation-required.html` | `401` + `invitation-required.html` | Two rows are worth reading twice. **No providers configured** is forwarded either way. With nothing to authenticate against there is no refusal to convert, and refusing here would turn a proxy that challenges nobody into one that refuses everybody. **Tenant selection returns `403`, not `401`.** The caller is authenticated; answering `401` would send a frontend back through a login it has already completed. This matches the `403` AuthProxy already returns when an authenticated user belongs to no organization. *** ## Upgrading This changes the status code an existing deployment returns to non-browser callers, and one case is worth checking before rolling out: **an HTTP liveness or readiness probe pointed at AuthProxy**. A probe sends `Accept: */*` and no fetch metadata, so it is not a navigation. Pointed at `/` it used to get `200` (the selection page) or `302` (the provider redirect) — both of which a probe reads as healthy — and now gets `401`, which it reads as unhealthy. The pod then fails to become ready. That probe was never testing much: it asserted that the login chooser renders, not that anything behind the proxy works. Replace it with one of: * The [management listener](/authproxy/configuration/management-listener/) — an opt-in private port carrying AuthProxy’s own `/health/live` and `/health/ready`. This is the probe for the *proxy*: liveness answers while every dependency is down, and readiness verifies that the instance could actually serve an authenticated request. * A path the application serves and the deployment declares in [`AnonymousPaths`](/authproxy/configuration/services/#anonymous-paths) — this actually exercises the proxy *and* the application, which is what a readiness probe is for. * A TCP socket probe, if all you need is “the container is listening”. Note that this proves only that a process accepted a connection — not that AuthProxy could serve anybody. On a deployment in [`CapabilityOnly`](/authproxy/configuration/admission/) admission mode the middle option is not available either — a declared anonymous path answers `404` to an unadmitted probe. Use the management listener. Note that the bare `/` cannot be declared anonymous — it would match every request and turn the whole service anonymous, so it is rejected. Name a real path. *** ## Consequences for clients * `!response.ok` is now a correct check against AuthProxy for any non-navigating caller. It was not before. * A client that deliberately wants the selection page — a custom login shell rendering it in a frame, for instance — should send `Sec-Fetch-Dest: document`, or navigate to `/.cratis/select-provider` directly. That path is in the authentication UI skip list and is never intercepted. * Paths a service declares in [`AnonymousPaths`](/authproxy/configuration/services/#anonymous-paths) never reach any of this. They are forwarded to the application, which answers them itself. # Receiving Webhooks > Let inbound webhooks reach your service through AuthProxy without a user session, depending on whether you control the sender. An inbound webhook is a request from a system that has no user session and never will. AuthProxy has two mechanisms for it, and which one applies depends on a single question: **do you control the sender?** *** ## Choosing the mechanism | The sender is | Use | Why | | --- | --- | --- | | A service or application you control | [Client credentials](/authproxy/configuration/authentication/#back-channel-client-credentials) | The sender can exchange credentials at `/.cratis/token` for a bearer token scoped to a route prefix. The proxy authenticates it, and the application receives a caller it can trust. | | A third party — Stripe, GitHub, Slack, an e-sign provider | [`AnonymousPaths`](/authproxy/configuration/services/#anonymous-paths) | They will not perform a token exchange. They sign the request their own way, and only the application knows how to check it. | If you control the sender, prefer client credentials. It keeps authentication in the proxy, which is what the proxy is for. Reach for `AnonymousPaths` when the sender’s scheme is not yours to choose. *** ## Third-party webhooks Declare the specific path the provider posts to: ```json { "Cratis": { "AuthProxy": { "Services": { "core": { "Backend": { "BaseUrl": "http://core:8080/" }, "AnonymousPaths": [ "/api/webhooks/payments" ] } } } } } ``` ### What AuthProxy does * Forwards the request to the service instead of answering it with a sign-in page. * Strips every inbound `x-ms-client-principal*` header and the tenant header (`x-cratis-tenant-id`, and the legacy `Tenant-ID`), exactly as it does for every other request. A caller cannot assert an identity. * Injects no principal headers, because there is no session. ### What AuthProxy does not do **It does not authenticate the request.** A declared path is reachable by anyone who knows the URL. The signature check is the application’s, and it is not optional — without it the endpoint accepts anything that anyone posts to it. A typical provider signs a request with an HMAC over the raw body plus a timestamp. Verifying it means: 1. Reading the **raw body bytes**, before any deserialization. 2. Recomputing the signature with the shared secret, following that provider’s exact canonical form. 3. Comparing in **constant time**. 4. Rejecting timestamps outside a short window, so a captured request cannot be replayed. ### Why the proxy does not do this for you Not an omission — a deliberate boundary: * **Every provider canonicalizes differently.** Stripe signs `timestamp.body`, GitHub signs the body alone, Slack signs `v0:timestamp:body`, and they disagree on hex versus base64 and on tolerance windows. Configuring that generically is harder to get right than implementing it once in the application. * **It requires buffering the body.** AuthProxy streams request bodies. Buffering them to compute a signature changes the memory profile of the path and risks altering exactly the bytes being verified. * **The application needs the raw body regardless**, for idempotency and for storing the event, so it is already holding the thing that has to be checked. *** ## Keeping the opening small * **Name the exact path.** Entries are prefixes and cover everything beneath them, so `/api/webhooks` opens every current and future webhook route, including ones added later by someone who did not know the prefix was public. `/api/webhooks/payments` opens one. * **Reject the methods you do not serve.** A webhook receiver is almost always `POST` only. * **Treat the payload as untrusted until verified**, including any identifiers used to look up records. * **Re-read the declared paths when reviewing the deployment.** They are the surface reachable without a session, and they are the shortest list worth auditing. For the sibling case — a page or API a *person* reaches without an account, such as a magic-link landing page — see [Public application surfaces](/authproxy/configuration/public-surfaces/). # Well-Known Pages > Override the built-in AuthProxy HTML pages for provider selection, invitation and tenant errors, and HTTP errors by mounting your own pages. AuthProxy serves built-in HTML pages for a range of conditions — provider selection, invitation errors, tenant errors, and generic HTTP errors. Every page can be **overridden** by mounting a directory of custom pages into the container. *** ## Built-in pages The following pages are included with AuthProxy and are served automatically when the corresponding condition is detected: | File name | Condition | HTTP status | | --- | --- | --- | | `404.html` | The requested resource was not found. | 404 | | `403.html` | The identity resolver denied access. | 403 | | `not-authorized.html` | The caller is signed in but does not satisfy the claim requirements declared in [`Cratis:AuthProxy:Authorization`](/authproxy/configuration/authorization/). | 403 | | `tenant-not-found.html` | The resolved tenant does not exist in the platform (see [Tenant verification](/authproxy/configuration/tenancy/#tenant-verification)). | 404 | | `no-organization.html` | The caller is signed in and tenant resolutions are configured, but no tenant could be resolved and no lobby redirect applies (see [Tenancy](/authproxy/configuration/tenancy/) and [Tenant selection](/authproxy/configuration/tenant-selection/)). | 403 | | `select-provider.html` | A protected resource was requested and multiple identity providers are configured, or a sign-in failed (see [Failed Sign-ins](/authproxy/configuration/failed-sign-ins/)). The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider, and shows a message when a `reason` query parameter is present. | 200 | | `select-tenant.html` | Tenant selection is enabled and the authenticated user has not selected a tenant yet. The page reads the `.cratis-tenants` cookie to render selectable tenants. | 200 | | `service-unavailable.html` | The tenant selection endpoint (`TenantsEndpoint`) could not be reached or did not return a usable answer, so the caller’s tenants could not be determined. | 503 | | `invitation-expired.html` | The JWT token on an invite link has passed its expiry time. Served in either phase — when the link is followed (Phase 1) and when the token is re-validated at the exchange (Phase 2). | 401 | | `invitation-invalid.html` | The JWT token on an invite link is malformed or has an invalid signature. Served in either phase — when the link is followed (Phase 1) and when the token is re-validated at the exchange (Phase 2). | 401 | | `invitation-select-provider.html` | A valid invite link was followed and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 | | `invitation-subject-already-exists.html` | The authenticated user’s subject is already associated with an existing account during invite exchange (Phase 2). | 409 | | `invitation-email-unavailable.html` | Gateway email binding is enabled (`Invite.EmailClaim`), but the identity provider supplied no authenticated-session email address (Phase 2). | 403 | | `invitation-email-mismatch.html` | Gateway email binding is enabled (`Invite.EmailClaim`), and the identity provider supplied another address or did not report the address as verified (Phase 2). With `Invite.Attestation` configured, a missing, duplicated, malformed or non-`true` `email_verified` claim counts as not verified. | 403 | | `link-select-provider.html` | The embeddable provider-selection page of the [credential-link flow](/authproxy/configuration/link/), served at `/.cratis/link`. Lists providers, opens the chosen provider’s link challenge in a top-level window, and reports the outcome to its embedding parent. | 200 | | `link-complete.html` | A credential link completed, at `/.cratis/link/complete`. Broadcasts the completion on the link flow’s `BroadcastChannel` and closes its window. | 200 | | `link-failed.html` | A credential link did not complete — the provider round-trip failed or the exchange was refused (see [Credential Linking](/authproxy/configuration/link/)). Broadcasts the failure so an embedding selection page can offer a retry. | 403 | *** ## Overriding pages Mount a directory into the container and point `Cratis:AuthProxy:PagesPath` at the mount path. AuthProxy looks up each page by its conventional file name inside that directory. Any page file that is present overrides the built-in default; missing files fall back to the built-in version. ```json { "Cratis": { "AuthProxy": { "PagesPath": "/mnt/pages" } } } ``` Equivalent environment variable: ```dotenv Cratis__AuthProxy__PagesPath=/mnt/pages ``` ### Container mount example (Docker Compose) ```yaml services: authproxy: image: cratis/authproxy:latest volumes: - ./my-pages:/mnt/pages environment: Cratis__AuthProxy__PagesPath: /mnt/pages ``` ### Page assets Pages can reference stylesheets, images, and other assets.\ Place asset files in the same pages directory — they are served at the `/_pages/` URL prefix. ```html Logo ``` *** ## Provider selection pages `select-provider.html` and `invitation-select-provider.html` both serve the same purpose — letting the user choose an identity provider when multiple are configured — but they are triggered by different flows: | Page | Triggered by | | --- | --- | | `select-provider.html` | Direct navigation to a protected resource when not yet authenticated. | | `invitation-select-provider.html` | Following a valid invite link. | Both pages receive provider data via the `.cratis-providers` cookie and work identically from a customization standpoint. See [Provider Selection Pages](/authproxy/configuration/invitation-provider-selection/) for the full cookie schema, JavaScript examples, and guidance on building a custom branded page. *** ## Invitation error pages Invitation error pages use **full descriptive names** rather than numeric HTTP status codes because they represent application-level conditions, not generic HTTP errors. ### `invitation-expired.html` Served when an invite token has a valid signature but has passed its `exp` claim. AuthProxy validates the token both when the `/invite/` link is followed (Phase 1) and again at the post-login exchange (Phase 2), so this page is served whenever an expired token is detected in either phase. The user should request a fresh invitation. ### `invitation-invalid.html` Served when an invite token is malformed, carries an invalid signature, or cannot be parsed at all. Because AuthProxy re-validates the token at the Phase-2 exchange — not only when the link is first followed — this page is also served if a tampered or forged token is presented at the exchange. ### `invitation-subject-already-exists.html` Served during Phase 2 (post-login invite exchange) when the exchange endpoint returns HTTP 409 Conflict, indicating that the authenticated user’s subject is already associated with an existing account. ### `invitation-email-mismatch.html` Served during Phase 2 (post-login invite exchange) when gateway email binding is enabled — the `Invite.EmailClaim` claim is configured — the invite token was issued for a specific email address — and the provider supplied another address or did not report the address as verified. This is distinct from a provider that supplied no address at all. What counts as unverified depends on the mode. In the legacy unsigned exchange (no `Invite.Attestation`) only an explicit `email_verified=false` mismatches, and an absent claim is forwarded as `null` for the backend to judge. With `Invite.Attestation` configured, completion fails closed: only a single `email_verified` claim parsing to exactly `true` counts as verified, so a missing, duplicated, malformed or non-`true` claim is answered with this page as well. ### `invitation-email-unavailable.html` Served during Phase 2 when gateway email binding is enabled and the identity provider supplied no authenticated-session email address. The invite may still target the correct person, but AuthProxy has no address evidence to compare. Use this page to ask the invitee to choose an account or provider that exposes an address, rather than telling them their address mismatched. Email binding is off by default. See [Invitation to Organization](/authproxy/configuration/lobby/invitation-to-organization/) for configuration and the limits of provider-supplied `email_verified` evidence. Replacement invitation selection, mismatch, and unavailable pages can display the HTML-encoded `{{invitedEmail}}` token. Mismatch pages can also display `{{assertedEmail}}` when the provider supplied a single usable address; otherwise it is empty, never inferred from a username. These display-only values are restricted to a validated invitation and its correlated callback. Static page URLs and unrelated failures receive no email evidence. See [Invitation email substitutions](/authproxy/configuration/error-pages/#invitation-email-substitutions) for the complete contract and an example. *** ## Tenant not found `tenant-not-found.html` is served when tenant verification is enabled and the platform reports that the resolved tenant ID does not exist. See [Tenant verification](/authproxy/configuration/tenancy/#tenant-verification) for how to configure the verification endpoint. *** ## No organization `no-organization.html` is served with a `403` when a user is signed in but belongs to no organization: tenant resolutions are configured, none resolved a tenant, and no lobby redirect applies. It is a `403` rather than a `401` so a frontend does not mistake it for “not signed in” and loop back to the login flow. See [Tenancy](/authproxy/configuration/tenancy/) and [Tenant selection](/authproxy/configuration/tenant-selection/). Override it by placing a `no-organization.html` in the `Cratis:AuthProxy:PagesPath` directory. The page is served to any caller, so a script that fetches a protected path such as `/.cratis/me` receives this HTML with a `403`; check the response status before parsing it. *** ## Not authorized `not-authorized.html` is served when [first-gate authorization](/authproxy/configuration/authorization/) is configured and an authenticated caller does not satisfy a required claim — a GitHub organization they do not belong to, a role they do not hold. It is distinct from `403.html`, which answers the *application* refusing a caller it does recognize; this one is the proxy refusing before the application is reached at all. If you override it, **keep a way to sign out**. It is the only refusal whose remedy is to come back as somebody else, and someone who signed in with the wrong account is otherwise stuck looking at a page that will not change. The built-in page links to `/.cratis/logout?redirect=/`: ```html Sign out ``` This is a good page to brand and to make specific — “ask #it-support for access to the planner” is far more useful to the person reading it than “not authorized”. # Upgrading > Changes between AuthProxy releases, compatibility transitions, and how to keep the previous behavior. Notes on releases that change what an unchanged configuration does. | Change | Summary | | --- | --- | | [Identity verification is required by default](/authproxy/upgrading/identity-verification-required-by-default/) | A service that has a backend is now verified by its `/.cratis/me` answer, and anything but an explicit positive denies. | | [Tenant and service headers follow Arc](/authproxy/upgrading/tenant-and-service-headers/) | Both current and legacy tenant and service headers are forwarded during the transition; legacy forwarding is deprecated, and client-supplied tenant headers are stripped. | # Identity verification is required by default > Service.IdentityVerification now defaults to Required, which fails closed. This note lists what now denies and how to keep the previous BestEffort behavior. `IdentityVerification` on a [service](/authproxy/configuration/services/) now defaults to `Required`. It used to default to `BestEffort`. This secure-default fix ships in a minor release. Services that need the previous enrichment-only behavior can opt out explicitly without changing their endpoint. ## Why Under `BestEffort` only an HTTP `403` from a service’s `/.cratis/me` endpoint refused a caller. An unreachable service, a timeout, any other failing status, or an empty, unparseable or negative body let the request through. A gateway whose identity service is down was therefore the most permissive exactly when it knew the least. `Required` closes that: only an explicit positive admits. ## What this affects Only a service that **configures an identity-details endpoint**: it declares a `Backend` and has not set `ResolveIdentityDetails` to `false`. A service with no backend, or with `ResolveIdentityDetails: false`, is never called and is unaffected. For an affected service that states no `IdentityVerification`, the following now return the `403` forbidden page instead of forwarding the request: | `/.cratis/me` on the service… | Before | Now | | --- | --- | --- | | is not implemented (`404`) | forwarded | **denied** | | cannot be reached (DNS, connection refused, TLS) | forwarded | **denied** | | does not answer within the timeout | forwarded | **denied** | | answers any other non-`2xx` (`400`, `401`, `500`, `502`, `503`…) | forwarded | **denied** | | answers `204`, or `200` with an empty or blank body | forwarded | **denied** | | answers a body that is not valid JSON | forwarded | **denied** | | answers JSON with no `isAuthorized` verdict | forwarded | **denied** | | answers `"isAuthorized": false`, or contradicting verdicts | forwarded | **denied** | | answers `403` | denied | denied | | answers `200` with `"isAuthorized": true` | forwarded | forwarded | Four consequences follow from the same change: * **A bounded wait.** An unstated `IdentityVerificationTimeout` is now ten seconds instead of the ambient 100-second HTTP client default. State a value to change it. * **A tenant is needed to verify.** Identity is verified per user and tenant, so AuthProxy now refuses to start when any such service exists and `TenantResolutions` is empty. The message names the key. Declare a tenant resolution (`Specified` with a tenant ID is the single-tenant one), or opt the service out as below. * **Callers with no resolved tenant are refused.** An authenticated request whose tenant does not resolve is denied rather than forwarded, except on [anonymous paths](/authproxy/configuration/services/#anonymous-paths) and AuthProxy’s own sign-in, invite and registration surfaces. * **A non-positive revalidation interval no longer remembers authorization in a cookie.** Previously, `Session.IdentityRevalidationInterval` set to zero or a negative value still produced a sealed record valid for ten minutes. With any `Required` service, no such record is written or reused. The independent `Session.IdentityResultCacheDuration` still applies (30 seconds by default); after that cache expires, AuthProxy asks the endpoints again. Set a positive `IdentityRevalidationInterval` to remember verified authorization, or explicitly select `BestEffort` for every affected service to keep the old fallback. An affected service also stops being served while its identity endpoint is down. That is the intent. Denials under `Required` log a warning naming the service, a bounded reason for the denial, and the exact opt-out: `Cratis__AuthProxy__Services____IdentityVerification=BestEffort`. A missing resolved tenant or exhausted identity-resolution wait produces one warning listing the Required services and their opt-outs. A canceled request is an ordinary client disconnect and logs only a debug denial, without an opt-out warning. ## Authorization cookies during upgrade Required verification now accepts a sealed authorization cookie only if it was issued after every currently Required participating service verified the caller. The cookie is bound to that service set; adding a Required service or switching a service from `BestEffort` to `Required` forces verification again. The in-memory result cache is also scoped to that set, so a cached `BestEffort` admission cannot become proof of Required verification after a configuration reload. Older cookies and cookies issued under `BestEffort` cannot bypass verification, including when Data Protection keys are preserved or older instances keep issuing cookies during a rolling upgrade. The first request carrying such a cookie is checked again, rather than trusting its previous revalidation window (ten minutes by default). This does not sign the user out unless verification denies access and session termination is enabled. Explicit `BestEffort` deployments can still reuse older cookies. ## What to do First check each service with a backend: does it answer `/.cratis/me` with `"isAuthorized": true` for users who may enter? If so, nothing needs to change. If a service only enriches the identity, or does not implement the endpoint, restore the previous behavior for that service explicitly: ```json { "Cratis": { "AuthProxy": { "Services": { "reporting": { "Backend": { "BaseUrl": "http://reporting-api:8080/" }, "IdentityVerification": "BestEffort" } } } } } ``` The exact one-line environment setting is (replace `` with the configured service name): ```bash Cratis__AuthProxy__Services____IdentityVerification=BestEffort ``` For example: `Cratis__AuthProxy__Services__reporting__IdentityVerification=BestEffort`. From an Aspire app host: ```csharp authproxy.WithIdentityVerification("reporting", IdentityVerificationMode.BestEffort); ``` If the endpoint should never be called, set `ResolveIdentityDetails` to `false` instead. See [Identity enrichment](/authproxy/configuration/services/#identity-enrichment) for the full behavior of each mode. # Tenant and service headers follow Arc > AuthProxy forwards both current and legacy tenant and service headers during the transition and strips client-supplied tenant headers. AuthProxy now also forwards the header names Arc uses by default. Existing backends continue to receive `Tenant-ID` and `Service-ID`, so this is a compatible transition in a minor release, not a rename. | | Before | Now | | --- | --- | --- | | Tenant forwarded to the backend, and sent to `/.cratis/me` | `Tenant-ID` | Both `x-cratis-tenant-id` and `Tenant-ID`, with the same resolved value | | Service selection headers | `Service-ID` | Both `x-cratis-microservice` and `Service-ID` accepted; the current name wins | | Service identifier forwarded to the backend | The supplied service header, if any | Both `x-cratis-microservice` and `Service-ID`, identifying the service selected by the route | | Service selection query parameter | `?service=` | `?service=` (unchanged) | ## Compatibility and deprecation No configuration change is needed to keep reading the legacy headers. Studio, Direct, and other existing backends that read `Tenant-ID` still receive the tenant AuthProxy resolved. Identity endpoints receive both tenant names too. Forwarding `Tenant-ID` and `Service-ID` is deprecated and will be removed in a future major release. Move backends to `x-cratis-tenant-id` and `x-cratis-microservice` before that release. This deprecation concerns forwarding; `Service-ID` is still accepted inbound, and `x-cratis-microservice` is now accepted too. Arc applications on their default tenancy settings now work without extra configuration. If you set `options.UseHeaderTenancy("Tenant-ID")` as a workaround, you can remove that one line. Keeping it also continues to work during the transition. ## Service selection `Service-ID` is still accepted inbound for routing and per-service authorization. `x-cratis-microservice` wins when a request carries both. The `?service=` query parameter remains the fallback when no header route matches, including when the header names an unknown service. Per-service authorization follows the matched route too, so an unknown header cannot bypass a query-selected service’s claim requirements. Both forwarded headers carry the configured destination name with its original casing (for example, `Studio`, not `studio`), even when a caller’s selector loses to an anonymous-path route or the single-service catch-all. Query-only requests also receive both headers. Requests to the single-service catch-all now receive both headers even with no selector. Arc’s default WebSocket and SSE observable connections use `?x-cratis-microservice=`, which AuthProxy does not route on yet. For multi-service deployments, set `Globals.microserviceWSQueryArgument = 'service';` in the Arc frontend to use the supported `?service=` query parameter. HTTP requests already use the supported `x-cratis-microservice` header. This query-argument mismatch remains tracked in [issue #154](https://github.com/Cratis/AuthProxy/issues/154). ## Inbound tenant headers are stripped Previously AuthProxy stripped an inbound `Tenant-ID` but not `x-cratis-tenant-id`. An Arc application with its default tenancy settings behind AuthProxy therefore ignored the tenant AuthProxy resolved and took one from the client, and the tenant decides the Chronicle namespace and the tenant-scoped read models. AuthProxy now removes both `x-cratis-tenant-id` and `Tenant-ID` from every inbound request, whatever their casing, and again from the forwarded request. It then writes the resolved value under both names. A request with no resolved tenant reaches the backend with neither tenant header, never a client value. Every inbound header whose name begins `x-ms-client-principal` is also removed, not just the three that AuthProxy writes, so `x-ms-client-principal-idp` and similar cannot be used to pass a claim to a backend. If you have configured Arc with a different tenant header, for example `UseHeaderTenancy("x-tenant")`, AuthProxy does not know about it and does not strip it or forward the tenant in it. Align Arc with `x-cratis-tenant-id`; until you do, put a rule in the ingress above AuthProxy that removes that header from client requests. ## What to do 1. Move backends that read `Tenant-ID` or `Service-ID` to the matching `x-cratis-*` names before the future major release. 2. Remove `UseHeaderTenancy("Tenant-ID")` from Arc applications behind AuthProxy if you want to use Arc’s defaults. 3. Optionally, change HTTP clients and Arc frontends to send `x-cratis-microservice` instead of `Service-ID`. 4. In multi-service deployments using Arc observable connections, set `Globals.microserviceWSQueryArgument = 'service';`. See [Tenancy](/authproxy/configuration/tenancy/), [Services](/authproxy/configuration/services/#multiple-services) and [Forwarded identity headers](/authproxy/configuration/authentication/#forwarded-identity-headers). # Build a full-stack feature > Put Chronicle, Arc, and Components together — go from a domain event to a working React screen in one vertical slice, with full-stack type safety. This is where the three products meet. You’ll build one **vertical slice** of a library app — registering an author and listing authors — end to end. Choose C#, Kotlin, Java, or TypeScript for the backend; the TypeScript backend is a source preview with no scaffold yet, so you start it from a sample. In every version, an [Arc](/arc/) command produces a [Chronicle](/chronicle/) event; a read model turns that event into a list; a React screen ([Components](/components/)) uses generated, type-safe proxies to call the backend. No hand-written API client, no DTO duplication. 🎯What you'll learn * how an Arc command’s handler returns a Chronicle event, and how the command’s id selects the event stream * how a read model and a query turn those events into the author list * how a React screen calls the backend through proxies generated from your backend types Keep the feature under **one module** — that’s the vertical-slice idea: you navigate by behavior, not by technical layer. Here’s the slice as an **[event model](/event-modeling/)** — read left to right, the user acts on a screen, a command records a fact, that fact is projected into a read model, and the next screen reads it: ``` UI/A: AuthorsUI/A: AuthorsC/RM: AuthorsC/RM: AuthorsStream: Authors AddAuthor RegisterAuthor id: uuid, name: string AuthorRegistered name: string Author Authors ``` The blocks are the slice, one per product: the `ui` screens are [Components](/components/), the `cmd` and the `rmo`’s query are [Arc](/arc/), the `evt` is [Chronicle](/chronicle/), and the build generates the typed proxies that connect them. Frames 01–03 are the **command pattern** (intent → fact); 03–05 are the **view pattern** (fact → read model → screen). Pick a backend language in the tabs below; your selection follows you through the steps. ## Before you start Choose one backend to follow: * **C#:** Install the [.NET 10 template](https://github.com/Cratis/Templates#how-to-use) and run `dotnet new cratis -o Library`. The [buildable Capstone sample](https://github.com/Cratis/Samples/tree/main/Capstone) contains this exact slice. From `Library/`, run `docker compose up -d`, `dotnet build`, then `dotnet run`. The development Compose file exposes its ports on all interfaces; bind them to `127.0.0.1` or use a trusted machine. Start the Vite frontend with the package manager selected by your scaffold (for example, `npm run dev`). For brevity, the C# slice keeps its four types (`AuthorId`, `RegisterAuthor`, `AuthorRegistered`, `Author`) in one `Authors/` folder at the project root; the generated proxies land beside them, which is why the screen below imports `./RegisterAuthor` and `./Author`. * **Kotlin or Java:** Run `dotnet new cratis --language Kotlin -o Library` or `dotnet new cratis --language Java -o Library`. With JDK 17 active, run `docker compose up -d`, `./gradlew build`, then `./gradlew bootRun` from `Library/`. As with C#, the development Compose file exposes its ports on all interfaces; bind them to `127.0.0.1` or use a trusted machine. Start the Vite frontend separately, following the generated [Kotlin](https://github.com/Cratis/Templates/blob/main/Templates/Cratis.Kotlin/README.md) or [Java](https://github.com/Cratis/Templates/blob/main/Templates/Cratis.Java/README.md) README. The Gradle build generates the proxies under `build/generated/arc-proxies/`. * **TypeScript (source preview):** There is [no TypeScript scaffold yet](https://github.com/Cratis/Templates/issues/61). Start from the [Arc TypeScript Library sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Library): from the Arc.TypeScript repository root run `yarn install`, `docker compose -f Samples/Library/docker-compose.yml up -d`, then `yarn workspace @cratis/arc.sample.library dev`. The TypeScript tabs mirror the sample’s `Features/Authors/AuthorId.ts`, `AuthorName.ts`, `Registration/Registration.ts` and `Listing/Listing.ts`, without its `Librarian` role, unique-name constraint and `authorsPage` query; [`AuthorName.ts`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/AuthorName.ts) adds the name validation. The sample builds its proxies beside each feature as `*.proxy.ts`; see its README for setup and the local-only development identity. The scaffolded C# and JVM Compose configurations start Chronicle with the default MongoDB choice, which persists the projected read model. Keep the four backend roles in one feature: host, author id, command/event, and read model/query. The sample layout differs by language; follow its neighboring files for imports and wiring. ## How the backends differ in this slice The steps below are the same for every backend. Where the code differs, this table says how, and each row names the step it applies to: | Behavior | C# | Kotlin and Java | TypeScript (preview) | | --- | --- | --- | --- | | Host change (step 1) | None; the scaffold’s `AddCratis` host | Add the author types to `KnownClientArtifacts` | None; the Library sample’s host | | Author id in the generated proxy (step 2) | `Guid`, from `EventSourceId` | `Guid`, from a UUID-backed concept | `Guid`, from `ConceptAs` | | Anonymous callers (step 3) | Accepted; the command declares no authorization | Rejected by default; the author types carry `@AllowAnonymous` | Accepted; the command declares no authorization | | Read model (step 4) | Projection from the event, with AutoMap | Reducer keyed by the event’s source id | Projection from the event | | Author list (step 4) | Live: re-renders when the projection changes | Live: observes the first page of 50 materialized instances; needs Chronicle 19.22.2 or later | Live: re-renders when the projection changes | | Generated proxies (step 4) | `dotnet build`, beside the types in `Authors/` | `./gradlew build`, under `build/generated/arc-proxies/` | The sample’s `build`, as `*.proxy.ts` beside each feature | ## 1. Wire the host (backend) The host connects Arc to Chronicle and exposes generated command and query endpoints. The scaffolds and Library sample supply this wiring; find it before changing the feature. C# and TypeScript need no change: their tabs show the existing host for orientation. On Kotlin and Java, add the three author types to the scaffold’s `KnownClientArtifacts` in `ChronicleConfiguration`, so an executable jar registers them. Keep the scaffold’s existing imports and add imports for the author types. Keep its sink fallback too: a non-MongoDB `--Database` generates `WellKnownSinkTypes.SQL` there, not `MONGODB`. * C# ```csharp var builder = WebApplication.CreateBuilder(args); builder.AddCratis( configureArcBuilder: arcBuilder => arcBuilder.WithMongoDB(configureMongoDB: mongoBuilder => mongoBuilder.WithCamelCaseNamingPolicy()), configureChronicleBuilder: chronicleBuilder => chronicleBuilder.WithCamelCaseNamingPolicy()); builder.Services.AddControllers(); builder.Services.AddMvc(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => options.AddConcepts()); var app = builder.Build(); app.UseRouting(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseWebSockets(); app.MapControllers(); app.UseCratis(); app.UseSwagger(); app.UseSwaggerUI(); app.MapFallbackToFile("/index.html"); await app.RunAsync(); ``` * Java ```java import io.cratis.chronicle.ChronicleOptions; import io.cratis.chronicle.artifacts.IArtifactActivator; import io.cratis.chronicle.artifacts.KnownClientArtifacts; import io.cratis.chronicle.connection.ChronicleConnectionString; import io.cratis.chronicle.sinks.WellKnownSinkTypes; import io.cratis.chronicle.spring.ChronicleProperties; import java.util.List; import kotlin.jvm.JvmClassMappingKt; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration(proxyBeanMethods = false) public class ChronicleConfiguration { @Bean public ChronicleOptions chronicleOptions( ChronicleProperties properties, IArtifactActivator artifactActivator, @Value("${spring.application.name:Unknown}") String applicationName ) { var artifacts = new KnownClientArtifacts(List.of( JvmClassMappingKt.getKotlinClass(Registered.class), JvmClassMappingKt.getKotlinClass(Listing.class), JvmClassMappingKt.getKotlinClass(ListingReducer.class), JvmClassMappingKt.getKotlinClass(RegistrationReactor.class), JvmClassMappingKt.getKotlinClass(AuthorRegistered.class), JvmClassMappingKt.getKotlinClass(Author.class), JvmClassMappingKt.getKotlinClass(AuthorReducer.class))); var sinkType = properties.getDefaultSinkTypeId(); if (sinkType == null) sinkType = System.getenv("CHRONICLE_SINK_TYPE"); if (sinkType == null) sinkType = WellKnownSinkTypes.MONGODB; var programIdentifier = properties.getProgramIdentifier() == null ? applicationName : properties.getProgramIdentifier(); return new ChronicleOptions( ChronicleConnectionString.Companion.parse(properties.getConnectionString()), programIdentifier, sinkType, properties.getAutoDiscoverAndRegister(), artifacts, artifactActivator); } } ``` * Kotlin ```kotlin import io.cratis.chronicle.ChronicleOptions import io.cratis.chronicle.artifacts.IArtifactActivator import io.cratis.chronicle.artifacts.KnownClientArtifacts import io.cratis.chronicle.connection.ChronicleConnectionString import io.cratis.chronicle.sinks.WellKnownSinkTypes import io.cratis.chronicle.spring.ChronicleProperties import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration(proxyBeanMethods = false) class ChronicleConfiguration { @Bean fun chronicleOptions( properties: ChronicleProperties, artifactActivator: IArtifactActivator, @Value("\${spring.application.name:Unknown}") applicationName: String ): ChronicleOptions = ChronicleOptions( connectionString = ChronicleConnectionString.parse(properties.connectionString), programIdentifier = properties.programIdentifier ?: applicationName, defaultSinkTypeId = properties.defaultSinkTypeId ?: System.getenv("CHRONICLE_SINK_TYPE") ?: WellKnownSinkTypes.MONGODB, autoDiscoverAndRegister = properties.autoDiscoverAndRegister, artifacts = KnownClientArtifacts( Registered::class, Listing::class, ListingReducer::class, RegistrationReactor::class, AuthorRegistered::class, Author::class, AuthorReducer::class ), artifactActivator = artifactActivator ) } ``` * TypeScript (preview) ```typescript import express from 'express'; import { ArcApplication } from '@cratis/arc.core'; import { cratisArc } from '@cratis/arc.express'; import { withChronicle } from '@cratis/arc.chronicle'; import { metadata } from './Features/generatedMetadata.js'; const builder = ArcApplication.createBuilder({ development: true, nativePrincipal: true, tenancy: { resolve: () => 'Default' }, query: { allowedOrigins: ['http://127.0.0.1:5173'] } }); builder.useGeneratedMetadata(metadata); withChronicle(builder, process.env.CHRONICLE_URL ? { connectionString: process.env.CHRONICLE_URL } : {}); await builder.discover(new URL('./Features/', import.meta.url)); const application = await builder.build(); const host = express(); // Demo-only trusted principal: never accept a role from an HTTP header. const demoIdentity = () => ({ principal: { id: 'demo-librarian', roles: ['Librarian'], isAuthenticated: true } }); const arc = cratisArc(application, demoIdentity); host.use(arc); const port = Number(process.env.PORT ?? 3000); const listener = host.listen(port, '127.0.0.1', () => console.log(`Library listening on http://127.0.0.1:${port}`)); const closeWebSockets = arc.injectWebSocket(listener, demoIdentity); const stop = async () => { await closeWebSockets(); await new Promise((resolve, reject) => listener.close(error => error ? reject(error) : resolve())); await application.dispose(); }; process.once('SIGINT', () => { void stop().catch(error => { console.error(error); process.exitCode = 1; }); }); process.once('SIGTERM', () => { void stop().catch(error => { console.error(error); process.exitCode = 1; }); }); ``` ## 2. Identify the event stream (backend) Chronicle uses the command key to select the author’s event stream. Every backend backs `AuthorId` with a GUID, so the generated command proxy types the id as a `Guid` whichever backend you chose. In C#, `AuthorId` derives from `EventSourceId`, which also makes it the read model’s key without a `[Key]` attribute: * C# ```csharp public record AuthorId(Guid Value) : EventSourceId(Value) { public static readonly AuthorId NotSet = new(Guid.Empty); public static implicit operator AuthorId(Guid value) => new(value); public static AuthorId New() => new(Guid.NewGuid()); } ``` * Java ```java import io.cratis.fundamentals.concepts.ConceptAs; import java.util.UUID; public record AuthorId(UUID value) implements ConceptAs, io.cratis.chronicle.concepts.ConceptAs { @Override public UUID getValue() { return value; } } ``` * Kotlin ```kotlin import io.cratis.fundamentals.concepts.ConceptAs as ArcConceptAs import io.cratis.chronicle.concepts.ConceptAs as ChronicleConceptAs import java.util.UUID data class AuthorId(private val id: UUID) : ArcConceptAs, ChronicleConceptAs { override fun value(): UUID = id override val value: UUID get() = id companion object { fun new(): AuthorId = AuthorId(UUID.randomUUID()) } } ``` * TypeScript (preview) ```typescript import { ConceptAs, Guid } from '@cratis/fundamentals'; export class AuthorId extends ConceptAs { static readonly valueType = Guid; static create(): AuthorId { return new AuthorId(Guid.create()); } } ``` ## 3. Register the author (backend) The command expresses intent; its handler returns the event that happened. Chronicle appends that event to the stream selected by the id. The Kotlin and Java command and read model carry `@AllowAnonymous`, as the scaffold’s own `Register` and `Listing` do, because Arc.Kotlin rejects anonymous callers by default and the JVM scaffolds have no sign-in: * C# ```csharp [Command] public record RegisterAuthor(AuthorId Id, string Name) { public AuthorRegistered Handle() => new(Name); } [EventType] public record AuthorRegistered(string Name); ``` * Java ```java import io.cratis.arc.artifacts.Command; import io.cratis.arc.artifacts.CommandKey; import io.cratis.arc.authorization.AllowAnonymous; import io.cratis.chronicle.events.EventType; @Command @AllowAnonymous public record RegisterAuthor(@CommandKey AuthorId id, String name) { public AuthorRegistered handle() { return new AuthorRegistered(name); } } @EventType public record AuthorRegistered(String name) { } ``` * Kotlin ```kotlin import io.cratis.arc.artifacts.Command import io.cratis.arc.artifacts.CommandKey import io.cratis.arc.authorization.AllowAnonymous import io.cratis.chronicle.events.EventType @Command @AllowAnonymous data class RegisterAuthor(@CommandKey val id: AuthorId, val name: String) { fun handle(): AuthorRegistered = AuthorRegistered(name) } @EventType data class AuthorRegistered(val name: String = "") ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { eventType } from '@cratis/chronicle/events'; import { command, key } from '@cratis/arc.core'; import { AuthorId } from '../AuthorId.js'; import { AuthorName } from '../AuthorName.js'; @eventType() export class AuthorRegistered { @field(AuthorName) name: AuthorName; constructor(name: AuthorName = new AuthorName('')) { this.name = name; } } @command() export class RegisterAuthor { @key() @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; handle(): AuthorRegistered { return new AuthorRegistered(this.name); } } ``` ## 4. Build and query the author list (backend) The read model describes the list entry; the event feeds it, and a query exposes the list. C# and TypeScript declare a projection from the event; in C#, AutoMap matches `AuthorRegistered.Name` to `Name`. Kotlin and Java use a reducer keyed by the event context’s source id, rather than a model-bound event mapping: * C# ```csharp [ReadModel] [FromEvent] public record Author(AuthorId Id, string Name) { public static ISubject> AllAuthors(IMongoCollection collection) => collection.Observe(); } ``` * Java ```java import io.cratis.arc.artifacts.FromServices; import io.cratis.arc.authorization.AllowAnonymous; import io.cratis.chronicle.events.EventContext; import io.cratis.chronicle.java.ReadModelsJavaBridge; import io.cratis.chronicle.observation.Reducer; import io.cratis.chronicle.IEventStore; import java.util.List; import java.util.concurrent.Flow; @io.cratis.arc.artifacts.ReadModel @io.cratis.chronicle.readModels.ReadModel @AllowAnonymous public record Author(String id, String name) { public static Flow.Publisher> allAuthors(@FromServices IEventStore store) { return ReadModelsJavaBridge.observeMaterializedInstancesPublisher(store.getReadModels(), Author.class, 0, 50); } } @Reducer public final class AuthorReducer { public Author registered(AuthorRegistered event, Author state, EventContext context) { return new Author(context.getEventSourceId(), event.name()); } } ``` * Kotlin ```kotlin import io.cratis.arc.artifacts.FromServices import io.cratis.arc.artifacts.ReadModel as ArcReadModel import io.cratis.arc.authorization.AllowAnonymous import io.cratis.chronicle.IEventStore import io.cratis.chronicle.events.EventContext import io.cratis.chronicle.observation.Reducer import io.cratis.chronicle.readModels.ReadModel as ChronicleReadModel import kotlinx.coroutines.flow.Flow @ArcReadModel @ChronicleReadModel @AllowAnonymous data class Author(val id: String = "", val name: String = "") { companion object { @JvmStatic fun allAuthors(@FromServices eventStore: IEventStore): Flow> = eventStore.readModels.materialized.observeInstances(Author::class, 0, 50) } } @Reducer class AuthorReducer { fun registered(event: AuthorRegistered, state: Author?, context: EventContext): Author = Author(id = context.eventSourceId, name = event.name) } ``` * TypeScript (preview) ```typescript import { field } from '@cratis/fundamentals'; import { query, readModel, service } from '@cratis/arc.core'; import { ChronicleReadModels } from '@cratis/arc.chronicle'; import type { Observable } from 'rxjs'; import { fromEvent } from '@cratis/chronicle/projections'; import { AuthorRegistered } from '../Registration/Registration.js'; import { AuthorId } from '../AuthorId.js'; import { AuthorName } from '../AuthorName.js'; @readModel() @fromEvent(AuthorRegistered) export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName; @query({ observable: true }, service(ChronicleReadModels)) static allAuthors(models: ChronicleReadModels): Observable { return models.observeAll(Author, author => author.id.toString()); } } ``` Build your backend now (`dotnet build`, `./gradlew build`, or the Library sample’s `yarn workspace @cratis/arc.sample.library build`). The build generates TypeScript command and query proxies: change a property on the backend and the frontend must compile against the updated shape. Chronicle projects asynchronously, so a successful command can precede the updated list. On Kotlin and Java, `allAuthors` observes a page of materialized instances (see *Author list* in the table above). ## 5. Build the screen (frontend) The screen below is the shared React flow from the [compiled C# Capstone sample](https://github.com/Cratis/Samples/tree/main/Capstone): run a generated command in a dialog, read a generated query, mount a route. Use the same Components with every backend, adjusting the generated proxy imports: * **C#:** The snippets below import proxies co-located in `Authors/`; the id in the command proxy is a `Guid`. * **Kotlin and Java:** Import `RegisterAuthor.ts` and `AllAuthors.ts` under `build/generated/arc-proxies/` via your scaffold’s `Api/` alias; the id in the command proxy is a `Guid`, as in C#. `AllAuthors` is observable, so `.use()` re-renders when the new author is projected. It can take a moment to appear, because Chronicle projects asynchronously. * **TypeScript:** Import `RegisterAuthor` from `Features/Authors/Registration/RegisterAuthor.proxy.ts` and `AllAuthors` from `Features/Authors/Listing/AllAuthors.proxy.ts`; keep `Guid.create()` for its id. Its [RegisterAuthorForm](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/Features/Authors/Registration/RegisterAuthorForm.tsx) seeds the id before validation with `initialValues`. 1. **A dialog that executes the command.** [`AddAuthor.tsx`](https://github.com/Cratis/Samples/blob/main/Capstone/Authors/AddAuthor.tsx) uses `CommandDialog` to instantiate and run the generated command and render the OK/Cancel footer — supply the fields: ```tsx import { CommandDialog } from '@cratis/components/CommandDialog'; import { InputTextField } from '@cratis/components/CommandForm'; import { RegisterAuthor } from './RegisterAuthor'; import { Guid } from '@cratis/fundamentals'; export const AddAuthor = () => ( command={RegisterAuthor} title="Add author" okLabel="Add" initialValues={{ id: Guid.create() }}> value={i => i.name} title="Name" /> ); ``` 2. **A screen that reads the query.** In [`Authors.tsx`](https://github.com/Cratis/Samples/blob/main/Capstone/Authors/Authors.tsx), the generated `AllAuthors` proxy is observable: `.use()` re-renders when the projection changes. Kotlin and Java observe a page of materialized instances (see the table). The button opens the dialog above: ```tsx import { useDialog } from '@cratis/arc.react/dialogs'; import { AllAuthors } from './Author'; import { AddAuthor } from './AddAuthor'; export const Authors = () => { const [AddAuthorDialog, showAddAuthor] = useDialog(AddAuthor); const [authors] = AllAuthors.use(); return (

    Authors

      {authors.data.map(a =>
    • {a.name}
    • )}
    ); }; ``` 3. **Mount the screen.** In the scaffold’s `App.tsx`, import `Authors` from `./Authors/Authors` and add this route inside the existing `` (alongside `/` and `/demo`): ```tsx } /> ``` Open `/authors` in the browser to see the list and the **Add author** button. ✓Recap * You built one vertical slice in your chosen backend: host, id, command, event, read model, query, and React screen for one behavior. * The screen calls proxies generated from your backend types, not a hand-written client; after a backend change, rebuild so the frontend compiles against the updated shape. * The command returned a fact; Chronicle appended it and built the read model, and the query delivered the list to the UI. Every backend updates the list live. Next, the [vertical slices series](/scenarios/vertical-slices/) grows the same Library domain one Event Modeling pattern at a time. ## Go deeper * C# backend: [Commands](/arc/backend/csharp/commands/) and [Queries](/arc/backend/csharp/queries/) in Arc, and the [Cratis package](/arc/backend/csharp/chronicle/cratis-package/) that `AddCratis` configures. * Kotlin and Java backend: the [Chronicle guide](/arc/backend/kotlin/guides/chronicle/) for Arc.Kotlin. * TypeScript backend: the [Arc TypeScript Library sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Library). * Every backend: [Projections](/chronicle/projections/) in Chronicle. * Frontend: [CommandDialog](/components/commanddialog/) and [data tables](/components/datatables/) in Components. * The reasoning: [Why developers choose Cratis](/why-cratis/) and [Why Arc](/arc/why-arc/). Questions? Ask on Discord Ask questions and get help from the Cratis team and other developers on the [Cratis Discord](https://discord.gg/kt4AMpV8WV). See [Community and help](/community/) for where else to go. # Chronicle > Cratis Chronicle is an open-source event-sourcing database and runtime with a .NET kernel and clients for .NET, TypeScript, Kotlin, Java and Elixir. Chronicle ## Event sourcing, the productive way Cratis Chronicle is an open-source event sourcing database and runtime with a .NET kernel and clients for .NET, TypeScript, Kotlin, Java and Elixir. It is built on the default architecture we recommend for information systems: store **every meaningful change as an immutable event** — the complete history of what happened — and turn those events into exactly the read models, reactions, and projections your application needs. Chronicle gives that event log a gRPC boundary, database-agnostic storage — MongoDB, PostgreSQL, Microsoft SQL Server, or SQLite — and tools you can actually operate. [Get started →](/chronicle/get-started/) · [Architecture →](/chronicle/architecture/) ## Start here [ Get started](/chronicle/get-started/) [Start the kernel, pick a client, and follow one event through the Chronicle loop.](/chronicle/get-started/) [ Tutorial](/chronicle/tutorial/) [Build a small event-sourced library one concept at a time.](/chronicle/tutorial/) [ Why Event Sourcing](/chronicle/why-event-sourcing/) [Understand why Chronicle keeps meaningful changes instead of only current state.](/chronicle/why-event-sourcing/) [ Run Chronicle locally](#run-chronicle-locally) [Start the development image, wait for readiness, and clean it up.](#run-chronicle-locally) [ Understand the architecture](/chronicle/architecture/) [Locate the protocol surfaces, kernel, runtime scopes, and persistent subscriptions.](/chronicle/architecture/) [ Inspect with Workbench](/chronicle/workbench/) [Use the bundled local browser inspection and projection-preview boundary.](/chronicle/workbench/) ## Run Chronicle locally For the shortest local path, use `latest-development`. It bundles Chronicle and MongoDB and tracks the newest published development image. When repeatability matters, pin a released version in your application or deployment configuration rather than copying an image digest into a getting-started command. This command exposes only the kernel port, `35000`; the bundled MongoDB stays inside the container. For a setup that also exposes MongoDB on port `27017` so local database tools can connect, see the [two-port host quickstart](/chronicle/get-started/choose-hosting-model/#run-chronicle-locally). 1. Pull and start the current development image with Chronicle available only on this machine. MongoDB remains internal to the container. ```bash docker run --pull=always --rm -d --name chronicle \ -p 127.0.0.1:35000:35000 \ cratis/chronicle:latest-development ``` 2. Wait for the local health endpoint to report readiness. The development image uses a self-signed certificate, so this command disables certificate verification only for this local check. ```bash curl --insecure --fail --retry 30 --retry-all-errors --retry-delay 1 \ https://localhost:35000/health ``` A successful response prints `Healthy`. At that point the kernel is ready for the next local evaluation step. 3. Stop and remove it when the evaluation is complete. ```bash docker rm -f chronicle ``` What this local start proves A `Healthy` response proves that this local development container started. It does not establish production suitability, support, scale, security, durability, compatibility, a managed service, client/provider parity, or support for every topology. ## Independent-use boundary The self-hosted Chronicle kernel and bundled Workbench can be started without Arc or Components. Applications and clients still need compatible packages, protocols, and configuration for the Chronicle server they connect to. ## Inspection surfaces Chronicle Workbench provides a bundled local browser surface for authorized inspection of Chronicle runtime state and preview of supported projection behavior. The Cratis CLI provides terminal workflows for inspecting and diagnosing Chronicle. Inspection capability does not imply complete administration, production-ready mutation, client parity, federation, managed service, support, or an SLA. ## Continue [ Architecture](/chronicle/architecture/) [Understand the kernel, protocol, store, namespace, and subscription boundaries.](/chronicle/architecture/) [ Workbench](/chronicle/workbench/) [Inspect Chronicle from the bundled local browser application.](/chronicle/workbench/) [ Cratis CLI](/cli/) [Terminal inspection and diagnosis for Chronicle.](/cli/) [ Chronicle source](https://github.com/Cratis/Chronicle/tree/main/Source) [Owning kernel, client, Workbench, and package source.](https://github.com/Cratis/Chronicle/tree/main/Source) # Chronicle in your language > Event sourcing for .NET, TypeScript, Kotlin, Java, and Elixir: Chronicle is an open-source (MIT) event-sourcing database with idiomatic clients on NuGet, npm, Maven Central, and Hex — plus an experimental Go client. Chronicle in your language ## One event-sourcing database, idiomatic clients Chronicle is an event-sourcing database and processing runtime with a language-agnostic gRPC/protobuf boundary. That means the event store is not tied to one runtime: the first-class .NET SDK and the TypeScript, Kotlin/Java, and Elixir clients all work against the same store, the same events, and the same projections. An experimental Go client is available too, and a Python client is coming soon. Everything is open source and MIT licensed. [Client SDK docs →](/chronicle/clients/) ## Event sourcing for .NET The .NET SDK is the first-class Chronicle client: typed access to event stores, event logs, read models, projections, reducers, reactors, constraints, migrations, namespaces, and hosting integration — including a one-line [.NET Aspire resource](/chronicle/hosting/aspire/). ```bash dotnet add package Cratis.Chronicle ``` In an ASP.NET Core application, add `Cratis.Chronicle.AspNetCore` instead; it brings in the core SDK and the `AddCratisChronicle` / `UseCratisChronicle` hosting integration. * [Event sourcing in .NET](/event-sourcing/dotnet/) — the dedicated landing page * [.NET client docs](/chronicle/clients/dotnet/) · [Get started](/chronicle/get-started/) * [`Cratis.Chronicle` on NuGet](https://www.nuget.org/packages/Cratis.Chronicle) * [Chronicle on GitHub](https://github.com/Cratis/Chronicle) ## Event sourcing for TypeScript and Node.js `@cratis/chronicle` is the idiomatic TypeScript client for Chronicle, with support for transactions, jobs, webhooks, compliance/PII, and OpenTelemetry. It builds on the `@cratis/chronicle.contracts` gRPC contracts package. ```bash npm install @cratis/chronicle reflect-metadata ``` The client uses decorators, so your project also needs `experimentalDecorators` and `emitDecoratorMetadata` enabled and must be an ES module project. The [TypeScript landing page](/event-sourcing/typescript/) lists the setup. * [Event sourcing in TypeScript and Node.js](/event-sourcing/typescript/) — the dedicated landing page * [TypeScript client docs](/chronicle/clients/typescript/) * [`@cratis/chronicle` on npm](https://www.npmjs.com/package/@cratis/chronicle) * [Chronicle.TypeScript on GitHub](https://github.com/Cratis/Chronicle.TypeScript) ## Event sourcing for Kotlin and Java `io.cratis:chronicle` is the idiomatic JVM client for Chronicle, usable from Kotlin and Java alike. It ships with a Spring Boot starter and a testing library. build.gradle.kts ```kotlin dependencies { implementation("io.cratis:chronicle:") // Or, in a Spring Boot application: implementation("io.cratis:chronicle-spring-boot-starter:") } ``` Replace `` with the current release from Maven Central. * [Event sourcing in Kotlin and Java](/event-sourcing/kotlin/) — the dedicated landing page * [Kotlin/Java client docs](/chronicle/clients/kotlin/) * [`io.cratis:chronicle` on Maven Central](https://central.sonatype.com/artifact/io.cratis/chronicle) * [Chronicle.Kotlin on GitHub](https://github.com/Cratis/Chronicle.Kotlin) ## Event sourcing for Elixir `cratis_chronicle` is the idiomatic Elixir client for Chronicle, with transactions, optimistic concurrency, webhooks, and resilient reconnection. mix.exs ```elixir defp deps do [ {:cratis_chronicle, "~> "} ] end ``` Use the current version from Hex; `mix hex.info cratis_chronicle` prints it. * [Event sourcing in Elixir](/event-sourcing/elixir/) — the dedicated landing page * [Elixir client docs](/chronicle/clients/elixir/) * [`cratis_chronicle` on Hex](https://hex.pm/packages/cratis_chronicle) * [Chronicle.Elixir on GitHub](https://github.com/Cratis/Chronicle.Elixir) ## Event sourcing for Go (experimental) `github.com/cratis/chronicle.go` is the Go client for Chronicle. It is an experimental foundation at v0.x: typed event registration, connection with TLS and OAuth, single and atomic batch appends, and event history reads are implemented. Observers, projections, and automatic lifecycle recovery are not yet implemented. ```sh go get github.com/cratis/chronicle.go@v0.11.0 ``` [v0.11.0](https://github.com/Cratis/Chronicle.Go/releases/tag/v0.11.0) is the latest released tag checked on 2026-10-20. The client remains experimental. * [Event sourcing in Go](/event-sourcing/go/) — the dedicated landing page * [Go client docs](/chronicle/go/) * [Chronicle.Go on GitHub](https://github.com/Cratis/Chronicle.Go) ## Event sourcing for Python — coming soon A Python client for Chronicle is in development. It is pre-release and not yet published to PyPI, so there is nothing to install yet — follow the repository for progress. * [Event sourcing in Python](/event-sourcing/python/) — the coming-soon landing page * [Chronicle.Python on GitHub](https://github.com/Cratis/Chronicle.Python) Pre-release The Python client is pre-alpha. APIs will change, and no package has been published. Watch the repository or ask in [the community](/community/) if it matters to your project. ## Any language, one contract Every client speaks the same gRPC/protobuf contracts, so any language with a gRPC implementation can talk to Chronicle. If your runtime is not listed above, you can implement the contracts directly. The clients other than .NET are released on their own schedules, and their major version numbers do not match the kernel’s. [Compatibility](/compatibility/) explains how to pair a client with a kernel. [Building a client ](/chronicle/building-a-client/)Implement the Chronicle gRPC contracts for a new language or runtime. [Chronicle architecture ](/chronicle/architecture/)How the kernel, the gRPC boundary, and the storage providers fit together. [Chronicle MCP server ](/chronicle-mcp/)Connect an AI agent to a running Chronicle — it works regardless of which client language your app uses. [Storage choice ](/chronicle/hosting/configuration/storage/)Run Chronicle over MongoDB (default), PostgreSQL, Microsoft SQL Server, or SQLite. # Chronicle MCP Server > Chronicle.Mcp connects an AI agent to a running Chronicle event store over the Model Context Protocol to explore, operate and model it. The Chronicle MCP server connects an AI agent to a running [Cratis Chronicle](https://github.com/Cratis/Chronicle) event store over the [Model Context Protocol](https://modelcontextprotocol.io). Point your agent at a Chronicle server and it can explore the domain, operate observers and jobs, and turn plain-language requests into Chronicle artifacts — always grounded in the store’s real schema. The server offers two complementary sets of capabilities: | Mode | What it does | Start here | | --- | --- | --- | | **Operate-side** | Inspect and operate a live system — browse events, watch observers, replay partitions, manage jobs. | [Operating a store](/chronicle-mcp/operate-side/) | | **Design-time** | Turn natural language into Chronicle artifacts — read models, projections, audits, catalogs — grounded in the actual event schema, never guessed. | [Design-time capabilities](/chronicle-mcp/design-time/) | ## Get started By the end of [Getting started](/chronicle-mcp/getting-started/) you will have the server wired into your agent and answering questions about a running Chronicle store. 1. [Getting started](/chronicle-mcp/getting-started/) — wire the server into your agent and ask your first question. 2. [Configuration](/chronicle-mcp/configuration/) — connection string, CLI config sharing, and authentication. 3. [How it works](/chronicle-mcp/concepts/) — the design-time vs. operate-side split and the grounding principle. ## Why this exists Chronicle already exposes a rich gRPC management API, and the [Cratis CLI](https://github.com/Cratis/cli) puts it at your fingertips. This server puts the same store in reach of an AI agent — so instead of you translating a question into a sequence of API calls, the agent does it, and instead of hand-writing read-model boilerplate, the agent proposes a grounded starting point you review like any other change. The design-time capabilities never invent field names or event shapes. Every suggestion is introspected from what the store actually has registered — see [How it works](/chronicle-mcp/concepts/#grounding-over-fluency). # How it works > Chronicle.Mcp exposes operate-side and design-time tools over one connection, and design-time output is grounded in the store's real schema. The Chronicle MCP server exposes two kinds of capability over one connection. Understanding the split is the fastest way to know which tool an agent will reach for — and why the design-time tools can be trusted not to make things up. ``` Server inspect & operate introspect schema propose code MCP over stdio gRPC AI agent Chronicle store Operate-side tools Design-time tools Reviewable proposal ``` ## Two modes **Operate-side** is about *inspecting and operating a live system*. These tools read events, list observers, report failed partitions, and manage jobs. They are the natural-language equivalent of running the [Cratis CLI](https://github.com/Cratis/cli) against a store. See [Operating a store](/chronicle-mcp/operate-side/). **Design-time** is about *turning intent into Chronicle artifacts*. Given a plain request, these tools introspect the store’s registered schema and produce read models, projections, audits, and catalogs. They are deliberately generative and advisory rather than operational. See [Design-time capabilities](/chronicle-mcp/design-time/). The two are complementary: an agent might use operate-side tools to discover what event types exist, then a design-time tool to scaffold a read model from them. ## Grounding over fluency Every design-time capability rests on one principle: **introspect the store before generating anything.** Field names, types, and event names come from Chronicle’s schema — never from a guess. * Event fields are read from the JSON schema registered with each event type, exposed through [describe an event type](/chronicle-mcp/design-time/describe-event-type/). * Consumers (projections, reducers, reactors) are read from the store’s projection and observer definitions. * When the store does not have enough information to answer confidently — for example a requested event type is not registered — the tool says so and returns no fabricated shape, rather than inventing one. This is worth over-engineering relative to the natural-language parsing itself: a plausible-sounding but wrong field name is worse than a tool that asks a clarifying question. ## Read-only against the store, generative against your codebase No design-time capability mutates the store. They need only read access to the event type registry, projection and observer definitions, and namespaces. Generated code is returned as a proposal — the agent shows it to you, and you review it like any other change and apply it to your own project. Projections are cheap to regenerate but should never appear unreviewed in a codebase. ## Which tools change state Only three tools change anything on the Chronicle server, and all three control jobs. They are registered only in the `Mutation` profile and require a matching authorization grant; the default `ReadOnly` profile does not expose them. See [Deployment profiles](/chronicle-mcp/deployment-profiles/). Every other tool — operate-side and design-time alike — only reads. | Tools | Effect | | --- | --- | | `stop_job`, `resume_job` | Change a job’s state. Not destructive — a stopped job can be resumed — and safe to repeat. | | `delete_job` | Removes a job. Destructive — the job cannot be recovered — and safe to repeat. | | All other tools | Read-only. | The server declares this to your agent through the standard MCP tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`), so an MCP client can let read-only tools run freely and ask you before a tool changes state. Every tool also declares `openWorldHint: false`: it talks only to the Chronicle server you configured, never to other services. ## Multi-tenancy awareness Chronicle isolates tenants by namespace. Every tool is scoped to an event store and namespace — defaulting to your configured defaults, overridable per call — so one tenant’s event shapes never leak into another’s suggestions by accident. # Configuration > Configure the Chronicle.Mcp server's connection string, credentials, default event store and namespace, and deployment profile. Configuration is optional. The server works out of the box with defaults suited to local development: * **Connection string:** `chronicle://localhost:35000` * **Credentials:** the development client id (`chronicle-dev-client`) and secret (`chronicle-dev-secret`) * **Event store / namespace:** `default` / `Default` * **Deployment profile:** `ReadOnly` — job-control tools are not registered. You only set values when you need to point at a different server or authenticate against a secured one. ## Resolution order For every value the server has not been given explicitly, it resolves in this order, first match wins: 1. Explicit MCP options — environment variables or `appsettings.json`. 2. The `CHRONICLE_CONNECTION_STRING` environment variable. 3. The active context in the Cratis CLI configuration at `~/.cratis/config.json`. 4. Built-in development defaults. Because step 3 reads the same file the [Cratis CLI](https://github.com/Cratis/cli) writes, a store you have already set up with `cratis context create` is picked up automatically — no separate configuration. ## Options All options live under the `Cratis:Chronicle:Mcp` configuration section. As environment variables they take the `Cratis__Chronicle__Mcp__` prefix: | Option | Environment variable | Description | | --- | --- | --- | | `Profile` | `Cratis__Chronicle__Mcp__Profile` | `ReadOnly` (default) or `Mutation`. An unknown value prevents startup. | | `Authorization.Principal` | `Cratis__Chronicle__Mcp__Authorization__Principal` | Identity this process acts for. Required and non-blank in `Mutation`. | | `Authorization.ExpiresAt` | `Cratis__Chronicle__Mcp__Authorization__ExpiresAt` | Policy expiration as an ISO-8601 timestamp. Required and in the future at mutation startup. | | `Authorization.RevokedPrincipals` | `Cratis__Chronicle__Mcp__Authorization__RevokedPrincipals__0` | Indexed list of revoked principals. Defaults to empty. | | `Authorization.Grants` | `Cratis__Chronicle__Mcp__Authorization__Grants__0__Principal` (and other grant fields) | Indexed grants naming principal, store, namespace, operations, and targets. Defaults to empty; `Mutation` requires a grant for its principal. | | `ConnectionString` | `Cratis__Chronicle__Mcp__ConnectionString` | The Chronicle connection string. Defaults to `chronicle://localhost:35000`. | | `Context` | `Cratis__Chronicle__Mcp__Context` | The CLI context to read connection details from. Defaults to the active context. | | `UseCliConfiguration` | `Cratis__Chronicle__Mcp__UseCliConfiguration` | Set to `false` to ignore `~/.cratis/config.json` entirely. | | `ClientId` / `ClientSecret` | `Cratis__Chronicle__Mcp__ClientId` / `...__ClientSecret` | Client credentials for authentication. Defaults to development credentials. | | `ApiKey` | `Cratis__Chronicle__Mcp__ApiKey` | An API key to authenticate with, as an alternative to client credentials. | | `EventStore` | `Cratis__Chronicle__Mcp__EventStore` | The default event store used by tools when none is specified. Defaults to `default`. | | `Namespace` | `Cratis__Chronicle__Mcp__Namespace` | The default namespace used by tools when none is specified. Defaults to `Default`. | Every tool defaults the event store and namespace to these configured values, so you can ask high-level questions and only name a store or namespace when you need a specific one. See [Deployment profiles](/chronicle-mcp/deployment-profiles/) for the complete grant format, wildcard rules, revocation, and denial codes. Profile and authorization settings do not fall back to CLI contexts or development credentials. ## Authentication Keep credentials in environment variables or a secret store, never in `server.json` or a committed `appsettings.json`. Chronicle authentication does not authorize MCP mutations. The server authenticates the same way the CLI does, and shares its token cache: * **Client credentials (OAuth):** when the connection string carries a client id and secret (or you set `ClientId`/`ClientSecret`), the server obtains an OAuth token from the server’s token endpoint, derived from the connection’s own address. Tokens are cached on disk under `~/.cratis/tokens`, the same location the CLI uses, so the two share credentials. * **API key:** set `ApiKey` (or embed `apiKey=` in the connection string) to authenticate with a static key instead. > The token endpoint is taken from the connected server’s address; there is no separate management-port setting. If you are upgrading from an older release that had a `ManagementPort` option, you can remove it — it no longer has any effect. ## Connect timeout Set `CHRONICLE_CONNECT_TIMEOUT_SECONDS` to override the default 5-second connect timeout when reaching a slow or remote server. # Deployment profiles > Choose read-only access or authorize specific Chronicle job mutations with a bounded deployment policy. Use the default `ReadOnly` profile when your agent only needs to explore a store, diagnose observers, or generate design-time artifacts. Enable `Mutation` only when this deployment should control specific jobs. ## Choose a profile | Profile | Exposed tools | Required policy | | --- | --- | --- | | `ReadOnly` (default) | Only tools explicitly declared read-only. Job inspection and design-time tools remain available. | None. | | `Mutation` | All tools, including `stop_job`, `resume_job`, and `delete_job`. Each mutation requires a matching grant. | A principal, a future expiration, and valid grants. | An unknown profile prevents startup. In `ReadOnly`, mutating tools are absent from the tool list and cannot be called. This is the safe local-development default, even when Chronicle uses development credentials. Changing the profile’s exposed tool set requires a restart. The mutation policy is read on every call; changes to a reloadable `appsettings.json`, including revocations, take effect without restarting. Environment variable changes require restarting the process. A running mutation deployment also denies mutations if its current profile is changed to `ReadOnly`. ## Understand the trust boundary Stdio has one caller per process. `Authorization.Principal` is the identity this deployment acts for, not a principal supplied by individual tool arguments. Run separate processes with separate policies for different callers, and protect who can launch the process or edit its configuration. Locality, a connection string, and Chronicle credentials never grant MCP mutation authority. MCP grants only narrow access: **Chronicle remains the authority** and applies its own authentication and authorization to reads and mutations alike. Keep credentials in environment variables or a secret store. Never put API keys, client secrets, or credential-bearing connection strings in `server.json` or a committed `appsettings.json`. The policy examples below contain no secrets. Express each authorization list in a single configuration source; a collection or object with both a scalar value (even an empty value) and children is ambiguous and rejected. ## Configure a mutation policy All settings live under `Cratis:Chronicle:Mcp`. A mutation deployment refuses to start when: * `Authorization.Principal` is blank. * `Authorization.ExpiresAt` is missing or is not in the future. * No grant names the configured principal. * Any grant has a blank principal, store, or namespace, empty operations or targets, or an operation that is not a known mutating tool. Startup errors name setting paths, never their values. Operation wildcards are invalid, so adding a new mutating tool never grants it implicitly. This complete `appsettings.json` authorizes stopping and resuming one job in the `orders` store’s `support` namespace. Replace the principal, scope, target, and expiration with the intended values before starting the server. Choose a short expiration suitable for the work. ```json { "Cratis": { "Chronicle": { "Mcp": { "Profile": "Mutation", "ConnectionString": "chronicle://localhost:35000", "UseCliConfiguration": false, "EventStore": "orders", "Namespace": "support", "Authorization": { "Principal": "support-agent", "ExpiresAt": "2027-01-01T00:00:00Z", "RevokedPrincipals": [], "Grants": [ { "Principal": "support-agent", "EventStore": "orders", "Namespace": "support", "Operations": ["stop_job", "resume_job"], "Targets": ["621d846d-56b7-49a4-8c1b-7703c6955c11"] } ] } } } } } ``` The equivalent environment configuration uses indexed arrays. These variables configure the server process; when using Docker, pass them into the container with `-e VARIABLE_NAME`. ```bash export Cratis__Chronicle__Mcp__Profile=Mutation export Cratis__Chronicle__Mcp__ConnectionString=chronicle://localhost:35000 export Cratis__Chronicle__Mcp__UseCliConfiguration=false export Cratis__Chronicle__Mcp__EventStore=orders export Cratis__Chronicle__Mcp__Namespace=support export Cratis__Chronicle__Mcp__Authorization__Principal=support-agent export Cratis__Chronicle__Mcp__Authorization__ExpiresAt=2027-01-01T00:00:00Z export Cratis__Chronicle__Mcp__Authorization__Grants__0__Principal=support-agent export Cratis__Chronicle__Mcp__Authorization__Grants__0__EventStore=orders export Cratis__Chronicle__Mcp__Authorization__Grants__0__Namespace=support export Cratis__Chronicle__Mcp__Authorization__Grants__0__Operations__0=stop_job export Cratis__Chronicle__Mcp__Authorization__Grants__0__Operations__1=resume_job export Cratis__Chronicle__Mcp__Authorization__Grants__0__Targets__0=621d846d-56b7-49a4-8c1b-7703c6955c11 ``` Start from an otherwise empty authorization configuration to make these examples equivalent. .NET configuration merges arrays across sources; an environment override does not remove grants already present in a JSON file. Matching is ordinal, case-sensitive, and exact. `*` matches any store, namespace, or target, but never an operation or principal. Target strings must exactly match the supplied `jobId` string. All dimensions must match the same grant. If a caller omits or leaves the store or namespace blank, the server checks the same [configured defaults](/chronicle-mcp/configuration/) the tool uses. To revoke a deployment principal, add it to `Authorization.RevokedPrincipals`. For an environment-based deployment, use `Cratis__Chronicle__Mcp__Authorization__RevokedPrincipals__0=support-agent` and restart. For a reloadable JSON policy, the next call observes the revocation. ## Handle denials A denial returns `isError: true`, with identical JSON in structured content and a text block: ```json {"error":{"code":"not_authorized","operation":"stop_job"}} ``` The response never includes store names, namespaces, targets, principals, credentials, or exception text. Denials are logged at Warning to stderr with only the code and operation. | Code | Meaning | | --- | --- | | `bad_request` | The target argument is missing or does not parse as its required type (`Guid` for `jobId`). | | `not_authenticated` | The configured deployment principal is blank or absent. | | `policy_invalid` | The current policy is malformed, cannot be bound, or no longer permits the mutation profile. | | `policy_expired` | The expiration is missing or has been reached. | | `principal_revoked` | The deployment principal is revoked. | | `not_authorized` | No grant matches the principal, store, namespace, operation, and target. | | `chronicle_denied` | Chronicle returns gRPC `PermissionDenied` or `Unauthenticated`, including for a read tool. | For mutating calls, checks run in table order from `bad_request` through `not_authorized`; the first failure wins and the tool is not invoked. `chronicle_denied` is returned only after a tool reaches Chronicle and Chronicle refuses the call. ## Confirm destructive operations `delete_job` is destructive and irreversible: the removed job and its progress cannot be recovered. MCP clients should ask the user to confirm the specific job and scope before calling it. The server does not prompt; a matching grant does not replace that confirmation. Use [job inspection](/chronicle-mcp/operate-side/jobs/) to check a job’s current state before stopping, resuming, or deleting it. # Design-time capabilities > The Chronicle.Mcp design-time tools turn plain-language requests into read models, projections, audits and catalogs grounded in the store's schema. The design-time tools turn a plain-language request into Chronicle artifacts — read models, projections, audits, and catalogs — grounded in the store’s real schema. They are read-only against the store and generative against your codebase: nothing is written to the store, and generated code comes back as a proposal you review. See [How it works](/chronicle-mcp/concepts/) for the principle behind them. ## Capabilities | Capability | Tool | Use it to | | --- | --- | --- | | [Describe the system](/chronicle-mcp/design-time/describe-system/) | `describe_system` | Deduce what the system is and is for — entities, lifecycles, read surfaces, automations — and tell its story. | | [Suggest the next event types](/chronicle-mcp/design-time/suggest-next-event-types/) | `suggest_next_event_types` | Find lifecycle gaps and get grounded suggestions for the event types to introduce next. | | [Ad-hoc projection](/chronicle-mcp/design-time/ad-hoc-projection/) | `run_ad_hoc_projection` | Answer “show all X with all details” by folding events into current state on demand — nothing registered. | | [Describe an event type](/chronicle-mcp/design-time/describe-event-type/) | `describe_event_type` | Read an event type’s real fields and types — the grounding primitive the others build on. | | [Read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/) | `scaffold_read_model` | Turn a set of event types into a reviewable read model + projection, grounded in their schema. | | [Unconsumed-event audit](/chronicle-mcp/design-time/unconsumed-event-audit/) | `audit_unconsumed_event_types` | Find events nothing reads, and consumers pointing at event types that no longer exist. | | [Event catalog](/chronicle-mcp/design-time/event-catalog/) | `generate_event_catalog` | Produce a living data dictionary — every event, its fields, and its consumers. | | [Causal trace](/chronicle-mcp/design-time/causal-trace/) | `explain_causal_trace` | Turn an event source’s raw log into a “what happened and why” narrative. | ## Prompts Beyond tools, the server exposes MCP prompts that package whole workflows — clients that surface prompts (for example as slash commands) can offer them directly: | Prompt | What it does | | --- | --- | | `describe_system` | Describe what the system is and is for, and tell its story. | | `suggest_next_event_types` | Propose the next event types, refined with domain knowledge. | | `query_system` | Answer a natural-language question (“show all employees with all details”) with an ad-hoc projection. | ## How an agent combines them A typical flow chains a few tools. To answer *“Show all employees with all details”* an agent might: 1. Understand the system with [describe the system](/chronicle-mcp/design-time/describe-system/) — which entities exist and which event types carry which facts. 2. Fold the relevant event types into current state with an [ad-hoc projection](/chronicle-mcp/design-time/ad-hoc-projection/) and present the result. 3. If the question keeps coming back, propose a permanent read model with [read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/). Because the tools return structured data rather than prose, the agent does the natural-language reasoning while the store supplies the facts. ## Roadmap The [capability hand-off](https://github.com/Cratis/Chronicle.Mcp) sketches ten design-time capabilities. The tools above cover the grounding-and-generation core plus system understanding, evolution suggestions, and ad-hoc querying. The remaining ideas — projection-vs-reducer advice, consistency-boundary (DCB) advice, schema-evolution assistance, spec scaffolding, and constraint suggestions — build on the same introspection pipeline and are candidates for future releases. # Ad-hoc projection > run_ad_hoc_projection folds Chronicle events into current state on demand, without registering or persisting a projection. *“Show all employees with all details.”* In an event-sourced system, answering that means folding events into current state — and if no projection happens to exist for exactly that question, the answer traditionally requires writing one, deploying it, and waiting for it to catch up. `run_ad_hoc_projection` removes that loop: it materializes the answer on demand, straight from the store, without registering or persisting anything. Given the event types involved, the tool reads the matching events from the event sequence and folds them into per-event-source instances with the same semantics as an AutoMap projection: properties merge by name, later events overwrite earlier values, and removal events drop the instance. The result is the current state of every instance — computed in the moment, discarded after. ## The natural-language flow The assistant resolves the question to event types; the tool does the folding: 1. **Understand the system** — [describe the system](/chronicle-mcp/design-time/describe-system/) or the [event catalog](/chronicle-mcp/design-time/event-catalog/) reveals the entities and which event types carry which facts. 2. **Pick the event types** — for *“all employees with all details”* that is every event contributing employee state: `EmployeeHired,EmployeeMoved,EmployeePromoted`, with `EmployeeTerminated` as a removal so former employees drop out. 3. **Run the projection** — the tool folds the events and returns the instances. 4. **Present the answer** — instances share a shape, so a table usually reads best. The `query_system` prompt packages exactly this flow: give it the natural-language question and it walks the assistant through resolving the event types and materializing the answer. ## Parameters | Parameter | Required | Description | | --- | --- | --- | | `eventTypes` | yes | Comma-separated event type ids to fold. | | `removedWith` | no | Comma-separated event type ids that remove an instance. | | `eventStore` | no | The event store. Defaults to the configured event store. | | `namespace` | no | The namespace. Defaults to the configured namespace. | | `eventSequenceId` | no | The event sequence to read from. Defaults to `event-log`. | | `eventSourceId` | no | Scope to a single instance. | | `limit` | no | Maximum instances returned. Defaults to 100. | ## What it returns * **Instances** — one per event source, each with its folded properties, the sequence number of the last event applied, and when it occurred. * **Counts** — how many events were folded and how many instances materialized (before the limit). * **Guidance** — how to present and refine the result. ## From ad-hoc to permanent An ad-hoc projection is a probe. When the same question keeps coming back, that is the signal it deserves a real read model — hand the same event types to [read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/) and turn the probe into a reviewable projection in your codebase. ## Honest limits * The folding is AutoMap-shaped: top-level properties merge by name, last write wins. Joins, children collections, and computed values are projection-engine territory — reach for a real projection when you need them. * Every matching event is read from the sequence on each call. That is exactly right for design-time exploration and operational spot checks; it is not a serving layer for production queries. ## Related * [Describe the system](/chronicle-mcp/design-time/describe-system/) — find out which event types exist before folding them. * [Event catalog](/chronicle-mcp/design-time/event-catalog/) — the field-level detail per event type. * [Read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/) — make a recurring ad-hoc projection permanent. # Causal trace > explain_causal_trace reads one event source's ordered history with correlation ids and causation chains to explain what happened and why. `explain_causal_trace` reads the ordered event history of one event source together with each event’s correlation id and causation chain, so an agent can narrate *what happened and why* instead of a support engineer reading raw JSON logs. It is the natural-language equivalent of `git blame` for state. ## When to use it * To answer a question like *“Why does this order show as cancelled?”* for support or operations staff. * To reconstruct the sequence of facts that produced an entity’s current state. * To relate an event to the wider flow that produced it, by following its correlation id. ## Parameters | Parameter | Required | Description | | --- | --- | --- | | `eventSourceId` | yes | The event source id to trace (for example the order id). | | `eventSequenceId` | no | The event sequence to read from. Defaults to `event-log`. | | `eventType` | no | A comma-separated event type filter. | | `eventStore` | no | The event store. Defaults to the configured event store. | | `namespace` | no | The namespace. Defaults to the configured namespace. | ## What it returns The event source’s events in sequence order. Each event carries: * Its sequence number, event type, generation, and when it occurred. * Its **correlation id** — the key that groups it with the wider flow it belongs to. * The identity that **caused** it, when known. * Its **causation** chain — the recorded causes, each with a type and details. * The event payload as JSON. ## Example **Prompt:** *“Why does order 8a3f… show as cancelled?”* The agent calls `explain_causal_trace` with the order’s id and walks the returned timeline: `OrderPlaced`, then `PaymentFailed` sharing a correlation id with a payment retry flow, then `OrderCancelled` caused by that failure. From the ordered facts and their causation, the agent produces a plain-language narrative — “the order was cancelled because payment failed twice” — without anyone reading the raw log. To widen the picture beyond a single entity, follow the correlation ids: events that share a correlation id belong to the same business flow, even across event sources. ## Related * [Get events](/chronicle-mcp/operate-side/events/) — read raw events from a sequence with filtering. # Describe an event type > describe_event_type reads an event type's real schema from the Chronicle store, with each property's JSON type and a suggested C# type. `describe_event_type` reads an event type’s real schema from the store — every property with its JSON type and a suggested C# type. It is the grounding primitive the other design-time tools build on: any generated projection, read model, or spec starts from the fields the event actually carries, not a guess. ## When to use it * Before generating anything from an event type, to confirm its shape. * To answer “what fields does `UserRegistered` have?” without opening source code. * As the first step when an agent maps a natural-language request onto concrete events. ## Parameters | Parameter | Required | Description | | --- | --- | --- | | `eventType` | yes | The event type id/name to describe (for example `UserRegistered`). | | `generation` | no | A specific generation to describe. The latest is used when omitted. | | `eventStore` | no | The event store. Defaults to the configured event store. | ## What it returns The event type’s identity (id, generation, owner, source, whether it is a tombstone, and the originating store when it is a foreign type received through the inbox), plus a list of properties. Each property carries its name, JSON schema type, a suggested C# type, any schema `format`, whether it is required, and its description. Returns `null` when no matching event type is registered — a signal that the name is wrong or the type has not been registered yet. ## Example **Prompt:** *“What does the UserRegistered event look like?”* The agent calls `describe_event_type` with `eventType: "UserRegistered"` and gets back the grounded shape: | Property | JSON type | C# type | Required | | --- | --- | --- | --- | | `Email` | string | `string` | yes | | `Name` | string | `string` | no | | `RegisteredAt` | string (date-time) | `DateTimeOffset` | no | The C# type suggestions map JSON schema types and formats to idiomatic C# — `guid`/`uuid` to `Guid`, `date-time` to `DateTimeOffset`, `integer` to `int`, arrays to `IEnumerable`, and so on. ## Related * [Read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/) uses these fields to build a read model. * [Event catalog](/chronicle-mcp/design-time/event-catalog/) returns the same field detail for every event type at once. # Describe the system > describe_system deduces a Chronicle system's entities, lifecycles, read models and automations from its registered event types. You inherit an event store — or return to one after six months — and the first question is always the same: *what is this system, and what is it for?* The answer is already in the store. Event names are past-tense facts about subjects, so the names themselves carry which entities exist and what can happen to them. `describe_system` reads that language back out. Ask *“describe the system in Sales”* and the tool deduces a structural model from the registered metadata: it clusters event types into the entities they describe, places every event in its entity’s lifecycle, and maps the read models and automations around them. The assistant then turns that model into prose — what the system is, and the story of how it behaves. ## How the deduction works Everything is deduced from what the store actually contains — nothing is guessed from code or documentation: * **Entities** come from event type names. `AuthorRegistered`, `AuthorNameChanged`, and `AuthorRemoved` all name the same subject, so they cluster as the entity **Author**. Property-scoped clusters fold into their parent: with both `CustomerRegistered` and `CustomerAddressUpdated` present, “CustomerAddress” folds into **Customer** so the whole lifecycle sits together. * **Lifecycle stages** come from the verb: *Registered/Created/Hired* mark **Creation**, *Changed/Renamed/Assigned* mark **Mutation**, *Received/Shipped/LoggedIn* mark **Activity**, *Corrected/Redacted* mark **Correction**, and *Removed/Closed/Cancelled* mark **Termination**. * **Properties** come from each event type’s registered JSON schema — the same field detail as [describe an event type](/chronicle-mcp/design-time/describe-event-type/). * **Read surfaces** are the projections and reducers with the read models they build — what the business watches. * **Automations** are the reactors and external observers — what the system does on its own. ## Parameters | Parameter | Required | Description | | --- | --- | --- | | `eventStore` | no | The event store to describe. Defaults to the configured event store. | | `namespace` | no | The namespace to resolve observers in. Defaults to the configured namespace. | ## What it returns * **Entities** — each with its events ordered by lifecycle stage, and every event’s properties. * **Read surfaces** — each read model with how it is built (projection or reducer) and the event types it derives from. * **Automations** — each observer with the event types that trigger it. * **Unconsumed event types** — facts the system records but nothing acts on yet. * **Statistics** — headline counts, and the namespaces present in the store. * **Narrative guidance** — instructions the assistant follows to turn the model into a description and a story. ## Example **Prompt:** *“Describe the system in HR — and tell me its story.”* From a store holding `EmployeeHired`, `EmployeeMoved`, `EmployeePromoted`, and `EmployeeTerminated`, plus an `AllEmployees` projection and an `OnboardingNotifier` reactor, the assistant deduces an HR system centered on the **Employee** entity and narrates it: an employee’s story begins when they are hired with a name and title; along the way they can move (the events carry the address vocabulary) and be promoted; the business watches all employees through `AllEmployees`; and when someone is hired, the system acts on its own to start onboarding. Termination ends the story. The `describe_system` prompt packages this workflow, so clients that surface MCP prompts can offer it directly. ## Related * [Suggest the next event types](/chronicle-mcp/design-time/suggest-next-event-types/) — the same deduced model, used to find the gaps. * [Event catalog](/chronicle-mcp/design-time/event-catalog/) — the flat data dictionary underneath the narrative. * [Ad-hoc projection](/chronicle-mcp/design-time/ad-hoc-projection/) — query the described system’s actual data. # Event catalog > generate_event_catalog builds a living data dictionary of every Chronicle event type, its fields and the observers that consume it. `generate_event_catalog` produces a living data dictionary from the event store: every event type, its fields, and every projection, reducer, and reactor that consumes it. Event names are the ubiquitous language of an event-sourced system, and this keeps that language documented and current straight from the store — useful onboarding material for new developers and grounding for any modeling question. ## When to use it * To onboard someone to a domain without hand-maintained documentation. * As grounding before a modeling conversation — the agent reads the catalog to know what already exists. * To regenerate a data dictionary on demand instead of maintaining one by hand. ## Parameters | Parameter | Required | Description | | --- | --- | --- | | `eventStore` | no | The event store. Defaults to the configured event store. | | `namespace` | no | The namespace to resolve observers in. Defaults to the configured namespace. | | `eventType` | no | A single event type id to scope the catalog to. Omit for the whole store. | ## What it returns One entry per event type, each with: * **Identity** — id, generation, owner, source, whether it is a tombstone, and the originating store for foreign types. * **Fields** — every property with its JSON and suggested C# type, the same detail as [describe an event type](/chronicle-mcp/design-time/describe-event-type/). * **Consumers** — the projections, reducers, and reactors that read the event, each with its type, identifier, and the read model it builds (when it builds one). An event type with an empty consumer list is an orphan — the same signal the [unconsumed-event audit](/chronicle-mcp/design-time/unconsumed-event-audit/) isolates. ## Example **Prompt:** *“Give me a catalog of our order events and who reads them.”* Scoped with `eventType` or filtered by the agent, the catalog shows, for each order event, its fields and its consumers — for example that `OrderPlaced` is read by the `OrderSummary` projection and the `ShippingNotifier` reactor, while `OrderCancelled` is read only by `OrderSummary`. From there the agent can explain the read side of the domain, or spot a gap. ## Related * [Describe an event type](/chronicle-mcp/design-time/describe-event-type/) — the same field detail for a single type. * [Unconsumed-event audit](/chronicle-mcp/design-time/unconsumed-event-audit/) — a focused view of the orphans in the catalog. # Read-model scaffolding > scaffold_read_model turns Chronicle event types into a reviewable read model and model-bound projection using the events' real schema. `scaffold_read_model` is the flagship design-time capability. It turns a set of event types into a reviewable read model and its model-bound projection — with field names and types taken from the events’ real schema, never invented. The output is always a proposal to review, never a silent write into your codebase. ## When to use it * To answer a request like *“Show me all the users registered”* with a working starting point in seconds instead of hand-written boilerplate. * When product or analytics folks describe a read model in plain English and an engineer needs something to approve in a pull request. The agent resolves the natural-language request to a concrete list of event types first — with [list event types](/chronicle-mcp/operate-side/exploring-the-store/), [describe an event type](/chronicle-mcp/design-time/describe-event-type/), or the [event catalog](/chronicle-mcp/design-time/event-catalog/) — then passes them to this tool. ## Parameters | Parameter | Required | Description | | --- | --- | --- | | `readModelName` | yes | The name of the read model to generate (for example `Users`). | | `eventTypes` | yes | A comma-separated list of event type ids to project from (for example `UserRegistered,UserEmailVerified`). | | `codeNamespace` | no | The C# namespace to generate into. Defaults to `ReadModels`. | | `eventStore` | no | The event store. Defaults to the configured event store. | ## What it returns * **Generated C# code** for a model-bound read model and projection. * **Chosen fields**, each traced back to the event it came from, so you can see why every property is there. * **Existing projections** that already read these events or target this read model — surfaced so you do not create a duplicate. * **Resolved and unresolved event types** — the requested types that were found, and any that are not registered. * **Notes** on caveats, such as a property that appears with two different types across events, or an identity that should become a strongly-typed concept. If none of the requested event types are registered, it returns no code and says so, rather than fabricating a shape. ## Example **Prompt:** *“Show me all the users registered.”* The agent resolves the request to the `UserRegistered` event, then calls `scaffold_read_model` with `readModelName: "Users"` and `eventTypes: "UserRegistered"`. The generated code is grounded in `UserRegistered`’s real fields: ```csharp // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using MongoDB.Driver; namespace ReadModels; /// /// Users read model, scaffolded from UserRegistered. /// Review the field selection and replace the Guid identity with a strongly-typed EventSourceId concept before use. /// [ReadModel] [FromEvent] public record Users( Guid Id, string Email, string Name) { /// /// Queries all Users instances. /// /// The read model collection. /// All Users instances. public static IQueryable AllUsers(IMongoCollection collection) => collection.AsQueryable(); } ``` ## Reviewing the proposal The scaffold is a starting point, not a finished slice. When you review it: * **Identity.** The key is generated as `Guid Id`. Replace it with a strongly-typed identity concept derived from `EventSourceId` — the note on the result reminds you. * **AutoMap.** Field names match the event’s property names so Chronicle’s AutoMap wires them; keep names aligned or add explicit mapping where they diverge. * **`using` directives.** `[ReadModel]` and `[FromEvent]` come from Chronicle; most Cratis projects surface them through global usings. Adjust the usings to match your project. * **Existing projections.** If the result lists an existing projection that already answers the question, prefer surfacing that over adding a duplicate. ## Related * [Describe an event type](/chronicle-mcp/design-time/describe-event-type/) — inspect the fields before scaffolding. * [Unconsumed-event audit](/chronicle-mcp/design-time/unconsumed-event-audit/) — find events that still lack a read model. # Suggest the next event types > suggest_next_event_types finds gaps in a Chronicle event model, such as missing creation or termination events, and proposes events to close them. A growing event model develops blind spots: an entity that can be created but never removed, a value set once at registration that the business later needs to change, a lifecycle whose beginning is implicit. You usually discover these gaps the hard way — when a feature needs an event that does not exist. `suggest_next_event_types` finds them first. Ask *“what event types should I introduce next for the system in Sales?”* and the tool deduces the system’s entities and lifecycles from the registered event types (the same deduction as [describe the system](/chronicle-mcp/design-time/describe-system/)), then runs deterministic gap analysis over them. Every suggestion names the concrete gap it closes — these are grounded findings, not brainstorming. ## The gaps it finds * **No explicit creation** — an entity’s first recorded fact is a mutation or termination, so its lifecycle starts implicitly. Suggests an explicit creation event that gives projections a reliable initialization point. * **A life without an end** — the entity has a beginning but nothing ever removes, closes, or archives it. Suggests a termination event so read models can clear state (`[RemovedWith]`) and compliance and cleanup have a fact to act on. * **Facts frozen at creation** — a property is set when the entity comes to life and no later event ever touches it. If the business allows it to change, that change needs its own event. Identifier properties are excluded — those genuinely never change. ## Parameters | Parameter | Required | Description | | --- | --- | --- | | `eventStore` | no | The event store to analyze. Defaults to the configured event store. | | `entity` | no | An entity name to scope the suggestions to (case-insensitive). Omit for the whole system. | ## What it returns * **Suggestions** — each with a suggested name (past tense, one purpose), the entity and lifecycle stage it completes, the rationale naming the gap, and candidate properties drawn from the existing schema. * **The entities analyzed** — so the assistant can reason about the suggestions in context. * **Guidance** — instructions for refining the seeds with domain knowledge. ## The suggestions are seeds The analysis is deliberately mechanical so it never invents facts — which means the names need the domain’s own language. The guidance tells the assistant to refine them: a Book is *Withdrawn*, not *Removed*; an Order is *Cancelled*, not *Deleted*; and some facts really are immutable, in which case the right answer is to discard the suggestion and say why. ## Example **Prompt:** *“What event types should I introduce next for the system in CRM?”* From a store holding `CustomerRegistered` (with `Email`, `FullName`, `PhoneNumber`) and `CustomerAddressUpdated`, the analysis finds that nothing ever ends a customer’s life, and that email, name, and phone number are frozen at registration. The assistant proposes `CustomerRemoved` (or, refined, `CustomerOffboarded`), plus `CustomerEmailChanged`, `CustomerFullNameChanged`, and `CustomerPhoneNumberChanged` — each carrying its property from the existing schema — and notes which ones the business may not need. The `suggest_next_event_types` prompt packages this workflow for clients that surface MCP prompts. ## Related * [Describe the system](/chronicle-mcp/design-time/describe-system/) — the deduced model the analysis runs over. * [Unconsumed-event audit](/chronicle-mcp/design-time/unconsumed-event-audit/) — the complementary gap: events that exist but nothing reads. * [Read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/) — once the new events exist, project them. # Unconsumed-event audit > audit_unconsumed_event_types finds Chronicle event types nobody consumes and consumers that reference event types that no longer exist. `audit_unconsumed_event_types` cross-references every registered event type against everything that consumes it — projections, reducers, and reactors — and reports two kinds of silent data debt: * **Unconsumed event types** — data being written that no read model or reactor reads. * **Dangling references** — the inverse: a consumer that references an event type id which no longer exists in the registry, typically a dead projection left behind after a refactor. It needs no natural-language parsing and is cheap to run, which makes it a good periodic health check rather than only an ad-hoc query. ## When to use it * As a recurring audit to catch events recorded “just in case” that never became a read model. * After a refactor, to find projections or reducers still pointing at event types that have been removed. ## Parameters | Parameter | Required | Description | | --- | --- | --- | | `eventStore` | no | The event store. Defaults to the configured event store. | | `namespace` | no | The namespace to resolve observers in. Defaults to the configured namespace. | ## What it returns * The number of event types considered. * **Unconsumed** event types, each with its id, generation, owner, and whether it is a tombstone. The owner tells you whether it is a `Client` type a developer usually cares about or a framework-internal `Chronicle` type. * **Dangling references**, each naming the missing event type id and the consumer (its type and id) that still points at it. ## How consumption is determined The audit is grounded entirely in the store’s registered definitions: * **Projections** contribute the event types they read from — the keys of their `From`, `Join`, and removal definitions, walked into nested and child projections. * **Observers** (reactors, reducers, and projections) contribute the event types each observes. An event type is *unconsumed* when nothing in either set references it. A reference is *dangling* when it names an event type id that is not in the registry. ## Example **Prompt:** *“What events are we writing that nothing reads?”* The agent calls `audit_unconsumed_event_types` and reports back, for example, that `CartAbandoned` is written but read by no projection or reactor — a candidate for either a new read model or removal — and that a `LegacyOrderView` projection still references a `PriceQuoted` event type that no longer exists. ## Related * [Event catalog](/chronicle-mcp/design-time/event-catalog/) — the full picture of every event and its consumers, including the orphans this audit isolates. * [Read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/) — turn an unconsumed event into a read model. # Getting started > Run the Chronicle.Mcp server inside your MCP-capable agent and ask questions about a live Chronicle event store. By the end of this page you will have the Chronicle MCP server running inside your agent and answering questions about a live Chronicle store. ## Prerequisites * A running Chronicle server. If you do not have one, the [`docker-compose.yml` in the repository](https://github.com/Cratis/Chronicle.Mcp/blob/main/Source/docker-compose.yml) brings one up with `docker compose up -d`. * An MCP-capable agent (for example VS Code with an agent extension, or any client that speaks the Model Context Protocol over stdio). * Docker, to run the pre-built `cratis/chronicle-mcp` image. ## Wire it into your agent The server speaks MCP over stdio and ships as a container. Configure your agent to launch it. In VS Code, add it to your user settings or to a `.vscode/mcp.json` in your project: ```json { "servers": { "Chronicle": { "type": "stdio", "command": "docker", "args": [ "run", "-i", "--rm", "-eCratis__Chronicle__Mcp__ConnectionString=chronicle://host.docker.internal:35000", "cratis/chronicle-mcp" ] } } } ``` On macOS and Windows the host machine is reachable from the container at `host.docker.internal`. The connection string is the only value most setups need to change — everything else has a working local-development default. See [Configuration](/chronicle-mcp/configuration/) for the full resolution order and authentication. This configuration starts in the safe `ReadOnly` profile. You can inspect jobs, but `stop_job`, `resume_job`, and `delete_job` are not listed or callable. To enable authorized job control, configure a [Mutation deployment profile](/chronicle-mcp/deployment-profiles/). ## Ask your first questions Once the server is connected, your agent can reach the store. Start with the operate-side capabilities to confirm connectivity: * “List all event stores.” * “What event types are registered?” * “Are there any failed partitions?” Then try the design-time capabilities, which turn a plain request into a grounded artifact: * “Describe the system in this event store” → the agent deduces entities and lifecycles with [describe the system](/chronicle-mcp/design-time/describe-system/) and tells the system’s story. * “Show all employees with all details” → an [ad-hoc projection](/chronicle-mcp/design-time/ad-hoc-projection/) folds the events into current state on demand. * “What event types should I introduce next?” → grounded gap findings from [suggest the next event types](/chronicle-mcp/design-time/suggest-next-event-types/). * “Show me all the users registered” → the agent resolves the event types, then proposes a read model with [read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/). * “What events are we writing that nothing reads?” → an [unconsumed-event audit](/chronicle-mcp/design-time/unconsumed-event-audit/). * “Why does this order show as cancelled?” → a [causal trace](/chronicle-mcp/design-time/causal-trace/) of the order’s history. ## Where to go next * [Configuration](/chronicle-mcp/configuration/) — connect to a secured server, share the CLI’s context, or run without Docker. * [Deployment profiles](/chronicle-mcp/deployment-profiles/) — keep read-only access or grant bounded job-control authority. * [How it works](/chronicle-mcp/concepts/) — the two modes and why every design-time suggestion is grounded in real schema. * [Design-time capabilities](/chronicle-mcp/design-time/) — the full set of generative and advisory tools. # Operating a store > The Chronicle.Mcp operate-side tools let an agent inspect and operate a live Chronicle system, from browsing events to watching observers and managing jobs. The operate-side tools inspect and operate a live Chronicle system. They are the natural-language equivalent of running the [Cratis CLI](https://github.com/Cratis/cli) against a store: browse the domain, watch observers, read events, and manage jobs. Every tool defaults the event store and namespace to your [configured defaults](/chronicle-mcp/configuration/), so you only name a store or namespace when you want a specific one. ## Capabilities | Area | Tools | Page | | --- | --- | --- | | Exploring the store | `list_event_stores`, `list_namespaces`, `list_event_types`, `list_projections`, `list_read_models`, `get_read_model_instances`, `get_server_version` | [Exploring the store](/chronicle-mcp/operate-side/exploring-the-store/) | | Events | `get_events`, `get_tail_sequence_number` | [Events](/chronicle-mcp/operate-side/events/) | | Observer health | `list_observers`, `get_observer`, `list_failed_partitions`, `list_recommendations` | [Observer health](/chronicle-mcp/operate-side/observer-health/) | | Jobs | `list_jobs`, `get_job`, `get_job_steps`, `stop_job`, `resume_job`, `delete_job` | [Jobs](/chronicle-mcp/operate-side/jobs/) | Every operate-side tool is read-only except `stop_job`, `resume_job`, and `delete_job`, which change job state. These three tools are registered only in the `Mutation` profile and require a matching authorization grant; they are unavailable in the default `ReadOnly` profile. See [Deployment profiles](/chronicle-mcp/deployment-profiles/) and [Which tools change state](/chronicle-mcp/concepts/#which-tools-change-state). ## Things to ask * List all event stores. * List all event types in the `orders` event store. * Show me the events for order `8a3f…`. * Are there any failed partitions? * What observers use the event type `OrderPlaced`? * List all jobs, and show me the steps of job `…`. ## Operate-side and design-time together Operate-side tools discover *what is there*; design-time tools turn that into *artifacts*. A common pattern is to explore event types here, then hand them to [read-model scaffolding](/chronicle-mcp/design-time/read-model-scaffolding/). See [How it works](/chronicle-mcp/concepts/) for the full split. # Events > Use the Chronicle.Mcp get_events and get_tail_sequence_number tools to read events and the tail sequence number from an event sequence. These tools read from event sequences — the append-only logs at the heart of Chronicle. ## Tools | Tool | Description | | --- | --- | | `get_events` | Retrieve events from an event sequence, with optional filtering by sequence range, event source id, and event type. Returns event headers and JSON content. | | `get_tail_sequence_number` | Return the highest used sequence number (the tail) in an event sequence. | ## `get_events` | Parameter | Description | | --- | --- | | `eventSequenceId` | The sequence to read from. Defaults to `event-log`. | | `from` / `to` | The sequence-number range. `from` defaults to `0`; `to` is optional. | | `eventSourceId` | An optional event source id to filter by. | | `eventType` | An optional comma-separated event type filter (for example `UserRegistered` or `UserRegistered+1`). | | `eventStore` / `namespace` | Default to the configured values. | Use it to read what actually happened — for a whole sequence, a range, or a single event source. ## `get_tail_sequence_number` The tail is the highest used sequence number, not a total count of events — gaps can exist. Use it to gauge how far a sequence has progressed, for example when checking whether an observer has caught up. ## Narrating history `get_events` returns raw events. To turn one event source’s history into a “what happened and why” narrative — following correlation and causation — use the design-time [causal trace](/chronicle-mcp/design-time/causal-trace/) tool instead. # Exploring the store > Use Chronicle.Mcp tools to list a store's event stores, namespaces, event types, projections and read models, and read their current instances. These tools let an agent walk a Chronicle store’s structure — its stores, namespaces, event types, projections, read models, and current instances — to build up a picture of the domain before doing anything else. ## Tools | Tool | Description | | --- | --- | | `get_server_version` | Get version info from the server. Doubles as a connectivity check. | | `list_event_stores` | List all event stores on the server. | | `list_namespaces` | List namespaces within an event store. | | `list_event_types` | List registered event types, with id, generation, owner, and source. | | `list_projections` | List projection definitions, including the read model each projects into and its activity state. | | `list_read_models` | List read model definitions. | | `get_read_model_instances` | List the current instances of a read model, paged. | ## Typical flow Start broad and narrow down: 1. `get_server_version` — confirm the server is reachable. 2. `list_event_stores` then `list_namespaces` — find the store and tenant you care about. 3. `list_event_types` — see the domain’s vocabulary. 4. `list_projections` and `list_read_models` — see what is already derived from those events. 5. `get_read_model_instances` — read the current state a projection has produced. ## Going deeper on an event type `list_event_types` gives you the inventory. To see an event type’s actual fields and types, use the design-time [describe an event type](/chronicle-mcp/design-time/describe-event-type/) tool, or [generate an event catalog](/chronicle-mcp/design-time/event-catalog/) for the whole store at once. # Jobs > Use Chronicle.Mcp tools to list Chronicle jobs, inspect their progress and steps, and stop, resume or delete them under the Mutation profile. Chronicle runs long-lived work — replays, projections rebuilds, and other maintenance — as jobs. These tools let an agent list jobs, inspect their progress and steps, and control their lifecycle. The default `ReadOnly` profile exposes job inspection only. `stop_job`, `resume_job`, and `delete_job` require the `Mutation` profile and a matching grant for the principal, event store, namespace, operation, and job ID. See [Deployment profiles](/chronicle-mcp/deployment-profiles/). ## Tools | Tool | Description | | --- | --- | | `list_jobs` | List all jobs in a namespace, optionally filtered by job status. | | `get_job` | Get a specific job by id, including full details and status changes. | | `get_job_steps` | Get the steps of a specific job, optionally filtered by step status. | | `stop_job` | Mutation profile: stop a specific job. It transitions to a stopped status. | | `resume_job` | Mutation profile: resume a specific stopped job. | | `delete_job` | Mutation profile: delete a specific job. It transitions to a removing status. | ## Typical flow 1. `list_jobs` — see what is running or has run, filtered by status when you only care about, say, failed jobs. 2. `get_job` — inspect one job’s details and the history of its status changes. 3. `get_job_steps` — drill into the individual steps to see where a job is or where it stopped. 4. `stop_job` / `resume_job` / `delete_job` — control the job once you understand its state. ## A note on control operations `stop_job`, `resume_job`, and `delete_job` change a running system. Have the agent confirm the job id and its current state — with `get_job` — before invoking them, the same care you would take from the CLI. The server tells your MCP client that these three tools change state, so a client that asks before state-changing calls asks for them. All three are safe to repeat. `stop_job` and `resume_job` are not destructive — a stopped job keeps its progress and can be resumed. `delete_job` is destructive and irreversible: once a job is removed, it cannot be recovered. MCP clients should ask the user to confirm the specific job and scope before calling it; the server does not prompt. See [Which tools change state](/chronicle-mcp/concepts/#which-tools-change-state). # Observer health > Use Chronicle.Mcp tools to check the health of projections, reducers and reactors, find failed partitions and list store recommendations. Observers — projections, reducers, and reactors — consume event sequences to build read models and trigger side effects. These tools let an agent check their health, find failures, and surface maintenance the store recommends. ## Tools | Tool | Description | | --- | --- | | `list_observers` | List observers in a namespace, with health and replay status. Optionally filter by type (reactor, reducer, projection, or all). | | `get_observer` | Show detailed information about a specific observer — its type, running state, owner, handled sequence numbers, and observed event types. | | `list_failed_partitions` | List observer partitions that have failed and are paused. | | `list_recommendations` | List active maintenance recommendations the store has raised. | ## A troubleshooting flow When an observer is stuck or a read model looks stale: 1. `list_observers` — check the observer’s running state and how far its sequence numbers have progressed. 2. `get_observer` — inspect the observer in detail, including which event types it observes. 3. `list_failed_partitions` — see whether a partition has failed and paused the observer. 4. `list_recommendations` — see whether the store already recommends a fix. A **quarantined** observer does not auto-resume; an operator has to clear the quarantine. Use the [Cratis CLI](https://github.com/Cratis/cli) or the management API to replay, retry, or clear quarantine once you have diagnosed the cause here. ## Finding the consumers of an event type `get_observer` shows the event types a single observer consumes. For the reverse — every consumer of a given event type across the store, plus its fields — use the design-time [event catalog](/chronicle-mcp/design-time/event-catalog/). # Chronicle architecture > Chronicle's kernel, protocol, event-store, namespace, and persistent-subscription boundaries. Chronicle ## Kernel and protocol boundaries Chronicle uses a .NET/Orleans actor-based kernel behind gRPC/HTTP surfaces and supports multiple event stores, namespaces, and persistent event-store subscriptions with outbox/inbox sequences. 🎯Use this page to locate * the gRPC and HTTP protocol surfaces; * the .NET/Orleans kernel; * event-store and namespace runtime scopes; and * persistent-subscription outbox/inbox sequences. ## Runtime shape ``` gRPC surface .NET / Orleans kernel HTTP surface Event stores Namespaces Persistent event-store subscriptions Outbox / inbox sequences ``` | Boundary | Owns in this diagram | Does not imply | | --- | --- | --- | | Protocol surfaces | gRPC and HTTP entry boundaries around the kernel | Every client or tool uses both transports | | Kernel | .NET/Orleans runtime | Horizontal scale, throughput, recovery, or topology guarantees | | Event stores and namespaces | Separate runtime scopes | Client/provider parity or compatibility breadth | | Persistent subscriptions | Event-store subscription state with outbox/inbox sequences | Guaranteed or exactly-once delivery | ## Related inspection surfaces Chronicle Workbench provides a bundled local browser surface for authorized inspection of Chronicle runtime state and preview of supported projection behavior. The Cratis CLI provides terminal workflows for inspecting and diagnosing Chronicle. These statements do not assign either tool to a specific transport in the diagram above. Architecture is not an operational guarantee This page does not claim horizontal scale, throughput, guaranteed delivery, exactly-once behavior, client/provider parity, durability tier, compliance, or support for every topology. ## Continue [ Chronicle overview](/chronicle/) [Start Chronicle locally and understand what the runtime owns.](/chronicle/) [ Workbench](/chronicle/workbench/) [Inspect the running Chronicle instance from a local browser.](/chronicle/workbench/) [ Cratis CLI](/cli/) [Use terminal inspection and diagnostic workflows.](/cli/) # Building a Chronicle Client > Building a Chronicle client in a new language means solving gRPC bindings, token authentication and server discovery; this section shows how. Chronicle speaks gRPC. In theory that means any language with a gRPC implementation can talk to it. In practice, hand-rolling a client means solving the same handful of hard problems every client has to solve before it can do anything useful: generating strongly-typed bindings from the wire contract, exchanging credentials for a token and keeping that token fresh, discovering and load-balancing across a cluster of servers, reconnecting when a connection drops, and rejecting a server whose contract has drifted instead of silently sending it garbage. None of that is domain logic. It’s the same plumbing whether the client ends up written in Go, Python, Rust, or Java — and it’s exactly the plumbing Chronicle’s own team has already built more than once, for TypeScript, Elixir, and Kotlin. This section is that experience written down as a checklist, so a new client doesn’t have to rediscover it one gap at a time. **Start with [The checklist](/chronicle/building-a-client/checklist/).** It’s the whole section distilled into one page — work through it while you build, and come back to it whenever you’re not sure what’s left. The rest of the pages exist to explain the *why* behind each item. ## The shape of every Chronicle client so far Every client Chronicle has shipped follows the same layering, whether or not that was the plan going in: ``` Kernel .proto files (Source/Kernel/Protobuf) Generated contracts package (published, one per language) Idiomatic client (the thing developers import) Convenience / hosting packages (optional — ASP.NET Core, Spring Boot, …) ``` * **[The checklist](/chronicle/building-a-client/checklist/)** — every item below, pulled into one page to work through. * **[Two ways to start](/chronicle/building-a-client/starting-points/)** — use the `.proto` files directly, or ask the Cratis team to generate and publish a contracts package for your language. Most clients so far took the second path. * **[Layering an idiomatic client](/chronicle/building-a-client/layering-an-idiomatic-client/)** — why the generated contracts are deliberately not the client developers use, and how a raw client and optional convenience packages sit on top of them. * **[Empty results and query failures](/chronicle/building-a-client/empty-results/)** — why collection results are never null, and why initialized payloads do not replace checking query success. * **[Authentication and bearer tokens](/chronicle/building-a-client/authentication-and-bearer-tokens/)** — how a client turns connection-string credentials into a bearer token on every call, and keeps it fresh. * **[Clustering and the connection lifecycle](/chronicle/building-a-client/clustering-and-connection-lifecycle/)** — what a client has to implement to be a good citizen of a multi-server Chronicle cluster. * **[Connection string elements](/chronicle/building-a-client/connection-string-elements/)** — the object model a client SDK typically wraps around the connection string grammar. * **[Documentation and snippets](/chronicle/building-a-client/documentation-and-snippets/)** — how a new client repo’s `Documentation/` folder plugs into the shared Chronicle docs site. ## Talk to us first Before generating anything yourself, get in touch with the Cratis team. Every contracts package Chronicle has shipped so far — TypeScript, Elixir, Kotlin, and Python — was generated from the kernel’s own `.proto` files and published to the right registry (npm, Hex, Maven Central, PyPI) from Chronicle’s own release pipeline, version-locked to the kernel release it matches. That pipeline already exists; turning it on for a new language is normally a matter of days, not the weeks it takes to build a `protoc` toolchain from scratch and get the packaging right. See [Two ways to start](/chronicle/building-a-client/starting-points/) for what that conversation looks like and what you get out of it. Note This section is written for whoever builds and maintains a **new** Chronicle client — Cratis team members and community contributors alike. If you’re building an *application on* Chronicle rather than a new client SDK, you want the [get started](/chronicle/get-started/) guide instead. # Authentication and bearer tokens > How a Chronicle client authenticates — client credentials, API keys, or none — and how it obtains and refreshes bearer tokens. Every call a Chronicle client makes to the Kernel over gRPC needs a bearer token attached to it. This page describes the exchange a new client has to implement to get one, keep it fresh, and attach it correctly — reverse-engineered from the .NET client’s reference implementation, since that’s where the behavior is defined today. Note This page is about how a **client authenticates to the Kernel**. Chronicle also has `BearerTokenAuthorization`, `BasicAuthorization`, and `OAuthAuthorization` types in its .NET client — those configure *outbound* authorization for **Webhooks** and **External Services** targets the Kernel calls out to. They’re unrelated to how a client authenticates itself, and naming a new client’s own types the same way as those would be a confusing coincidence, not a pattern to follow. ## The three authentication modes Which mode a connection uses is decided entirely by what’s present in the connection string — there’s no separate flag: | Mode | Connection string carries | What happens | | --- | --- | --- | | Client credentials | `username:password@host` (or nothing at all) | Exchanged for a bearer token via OAuth’s `client_credentials` grant | | API key | `?apiKey=...` query parameter | Sent as-is (no exchange) | | None | `?auth=none` query parameter | No credentials presented at all — only works against a server with authentication turned off | Supplying both client credentials and an API key in the same connection string is an error (`AmbiguousAuthenticationMode`). Supplying none of them, without asking for `auth=none`, is not: the connection string selects client credentials and uses the development default below. Only a partial set — a username without a password, or the reverse — is an error (`MissingAuthentication`). See [Connection string elements](/chronicle/building-a-client/connection-string-elements/) for the full grammar. ## Client credentials really means OAuth client-credentials It’s easy to assume `username:password@host` is HTTP Basic auth. It isn’t. The username and password in the connection string map directly onto an OAuth `client_credentials` grant: username becomes `client_id`, password becomes `client_secret`, and the client exchanges them for an access token by calling the Kernel’s own token endpoint before making any other RPC: ```text POST https://{host}:{port}/connect/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&client_id={username}&client_secret={password} ``` The response is parsed for `access_token` and `expires_in` (a missing `expires_in` defaults to 3600 seconds). The client secret is never sent as a Basic-auth header, and it’s never sent on every call — only once, to get a token, which is what then goes on every call. ### Development defaults A connection string with no credentials at all still resolves to client-credentials mode — it substitutes two well-known development values rather than connecting anonymously: ```text client_id = chronicle-dev-client client_secret = chronicle-dev-secret ``` `chronicle://localhost:35000` and `chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000` are exactly equivalent. This is what makes local development work with no setup, and it’s also why those two values are not a secret worth protecting — they’re baked into every Chronicle client and meaningless outside a server explicitly configured to accept them. ## Attaching the token to a call Once a token exists, it goes on every outgoing gRPC call as a metadata header: ```text authorization: Bearer {access_token} ``` A gRPC client interceptor is the natural place to do this — it needs to run for unary calls and every streaming variant (client-streaming, server-streaming, duplex), and it needs one more piece of behavior beyond just attaching the header: if a call comes back `Unauthenticated`, force a token refresh and retry the call exactly once before giving up. That single retry is what makes a token that expires mid-session invisible to the caller instead of surfacing as a hard failure. Chronicle-issued access tokens target the `chronicle` audience. After upgrading a server that previously issued tokens without an audience, reacquire those tokens rather than reusing the old cache. External-authority tokens must match the server’s configured audience. Authenticated subjects must be nonblank; Chronicle no longer invents an actor identifier when a subject is missing. Bearer-only requests do not need the Workbench’s `X-CSRF-TOKEN` header. That protection applies to authenticated browser-cookie mutations; it does not change the bearer gRPC exchange described here. ## Keeping the token fresh A well-behaved client refreshes proactively rather than waiting to be rejected: * Track when the token was issued and how long it’s valid for (`expires_in`). * Treat it as needing renewal a margin *before* actual expiry — a 60-second margin is a reasonable default — so a call that starts just before expiry doesn’t race the clock. * If a refresh attempt fails (the token endpoint is briefly unreachable, say), keep serving the still-technically-valid cached token rather than failing every call until the endpoint recovers. Throttle repeated failed-refresh attempts (a few seconds between retries) so a down token endpoint doesn’t turn into a tight retry loop on every call. * Treat an *explicit* refresh request (triggered by the 401-retry above) differently from routine proactive renewal: it should bypass the failure throttle, since it’s evidence-driven rather than a guess that the token might be stale. ### Optional: caching a token to disk Short-lived processes — a CLI invoked once per command, a script run from cron — pay the full token-exchange cost on every single invocation if the token only ever lives in memory. A client worth using from tooling like that benefits from an optional decorator that persists the token to a local file (client ID, token, and expiry — nothing else) and reuses it across process invocations until it’s genuinely close to expiring, deleting the cache file and fetching fresh on an explicit refresh. Treat a missing or corrupt cache file as “no token” and fetch normally rather than failing — a cache is an optimization, never a dependency. ## What a new client needs to implement * Determine the auth mode from the connection string (client credentials / API key / none), with the same “empty means development defaults” and “both present is an error” rules. * For client credentials, perform the OAuth `client_credentials` exchange against `/connect/token` on the configured host. * Attach `authorization: Bearer {token}` to every call, across every streaming shape your gRPC library exposes. * Cache the token, refresh it proactively ahead of expiry, and retry once on a 401. * Never log a raw connection string or a raw token — see [Connection string elements](/chronicle/building-a-client/connection-string-elements/#never-log-the-unredacted-form) for why and how the .NET client avoids it. Next: [Clustering and the connection lifecycle](/chronicle/building-a-client/clustering-and-connection-lifecycle/) covers what happens before and after that first authenticated call — picking a server and staying connected to it. # The checklist > A top-to-bottom checklist for building a new Chronicle client, covering contracts, the idiomatic layer, authentication, clustering and connection strings. Everything below is pulled from the detailed pages in this section — this page exists so you can work through it top to bottom while you build, and come back to it when you’re not sure what’s left. Each item links to the page that explains the *why*. ## Before you write any code * [ ] Talk to the Cratis team about generating and publishing a contracts package for your language, rather than hand-rolling a `protoc` toolchain yourself. See [Two ways to start](/chronicle/building-a-client/starting-points/). ## Contracts (generated layer) * [ ] Treat the generated contracts package as read-only — never hand-edit it, never hand-maintain a fork of it. It’s regenerated by Chronicle’s own pipeline on every kernel release. * [ ] Depend on the *published* contracts package as an ordinary dependency in your idiomatic client. Don’t vendor it, and don’t build your idiomatic client in the same package. See [Layering an idiomatic client](/chronicle/building-a-client/layering-an-idiomatic-client/). ## The idiomatic client * [ ] Build it as a separate, hand-maintained package with its own release cadence — not inside the generated contracts package, and not by hiding hand-written code inside generated files. * [ ] Name and export it so nobody consuming it ever needs to import the contracts package directly. * [ ] Make it a complete, general-purpose client on its own: usable from a console tool, a script, or a background worker with no hosting framework required. A convenience/hosting package (an ASP.NET-Core- or Spring-Boot-style integration) is optional and comes later, once the idiomatic client is stable — see [Layering an idiomatic client](/chronicle/building-a-client/layering-an-idiomatic-client/#convenience-packages-sit-above-that-and-are-optional). ## Authentication * [ ] Parse all three authentication modes from the connection string: client credentials, API key, and none (`auth=none`). * [ ] For client credentials, implement the OAuth `client_credentials` exchange against `/connect/token` — the username and password in the connection string map to `client_id` and `client_secret`, not to HTTP Basic auth. * [ ] Fall back to the `chronicle-dev-client` / `chronicle-dev-secret` development defaults when the connection string carries no credentials at all. * [ ] Attach `authorization: Bearer {token}` to every outgoing call, across every streaming shape your gRPC library exposes. * [ ] Cache the token, refresh it proactively ahead of expiry, and retry exactly once on an `Unauthenticated` response. * [ ] Never log a raw connection string or a raw token — always log through a redacted rendering. See [Authentication and bearer tokens](/chronicle/building-a-client/authentication-and-bearer-tokens/). ## Clustering and the connection lifecycle * [ ] Support both connection-string forms: an explicit, comma-separated host list and `chronicle+srv://` DNS-based discovery. * [ ] Re-resolve the server list on *every* connect and reconnect, not just once at startup. * [ ] Ship at least `least-connections` (matching the real probe/reserve protocol against `/connections/count` and `/connections/reserve`) and `round-robin` (with a random start offset per instance). * [ ] Embed your contracts package’s compiled descriptor set, and run the wire-compatibility check before treating a new connection as usable. Fail hard on a real incompatibility — don’t degrade silently. * [ ] Run a keep-alive watchdog with capped exponential backoff, and reuse the same connect path for reconnects that the initial connect uses. See [Clustering and the connection lifecycle](/chronicle/building-a-client/clustering-and-connection-lifecycle/). ## The connection string object model * [ ] Parse the connection string into a typed value once. Don’t re-parse or grep the raw string wherever a setting is needed. * [ ] Compute the authentication mode from the parsed fields rather than storing it separately, so the two can’t drift apart. * [ ] Offer a fluent builder that validates as it assembles (reject “credentials and API key both present” at construction time, not at first connect). * [ ] Offer a redacted rendering of the connection string, and use it everywhere the client itself logs. See [Connection string elements](/chronicle/building-a-client/connection-string-elements/). ## Publishing * [ ] Publish the contracts package from Chronicle’s own release pipeline, version-locked to the kernel release it was generated from. * [ ] Publish the idiomatic client as its own artifact, on its own release cadence, once it’s stable enough to ship. * [ ] **The moment you add a second publishable module — a testing package, a convenience/hosting package — add it to your CI’s actual publish job in the same change that adds its build config.** This is not a hypothetical: `io.cratis:chronicle-testing` and `io.cratis:chronicle-spring-boot-starter` both had correct Maven coordinates and a working `mavenPublishing` block for weeks before anyone noticed the release workflow only ever ran `:Source:publishAndReleaseToMavenCentral`. Valid build config for a module that CI never tells to publish is indistinguishable, from the outside, from a module that doesn’t exist. ## Documentation * [ ] Create a `Documentation/` folder with a `toc.yml`, an `index.md`, and a `client-snippets/**` tree from day one. * [ ] Add a snippet validator and a CI workflow that runs it before your first snippet ships. * [ ] Don’t write your own “Events” or “Projections” pages — those concepts live in the shared Chronicle docs. Your repo only owns pages that are genuinely different per client: installation, connection setup, framework integration, troubleshooting. * [ ] If your artifact serves two languages the way Kotlin serves Java, plan for two parallel snippet trees (`client-snippets/`, `client-snippets-/`) from the start. See [Documentation and snippets](/chronicle/building-a-client/documentation-and-snippets/). Next: [Two ways to start](/chronicle/building-a-client/starting-points/) walks through the first decision in more depth. # Clustering and the connection lifecycle > What a new Chronicle client must implement for multi-server use: server discovery, load balancing, the wire-compatibility handshake and staying connected. [Connection Strings → Server support](/chronicle/connection-strings/server/) is the canonical reference for the connection string format, the load-balancer strategies, and DNS SRV lookup, written for someone configuring an existing client. This page covers the same ground from the other side: what a **new client** actually has to implement to participate correctly in a multi-server Chronicle cluster, and the parts of the picture that page doesn’t cover — the wire-compatibility handshake and the reconnect loop. ## Discovering the server list A connection string names either an explicit, comma-separated list of hosts (`chronicle://a,b,c`) or, with the `chronicle+srv://` scheme, a single DNS name to resolve via SRV records — the same mechanism `mongodb+srv://` uses. For SRV, the client queries `_chronicle._tcp.` and treats the returned targets, ordered by SRV priority then weight, as the candidate list. **This resolution happens fresh on every connect and every reconnect**, not once at startup — a server added to or removed from DNS is picked up automatically the next time the client needs to pick one, with no client-side configuration change and no restart. ## Picking a server: load balancer strategies A load balancer strategy is a single operation: given the current candidate list, return the one server to connect to next. It’s invoked on every connect, including every reconnect after a dropped session — a client doesn’t fail over to a fixed backup, it re-runs the same selection. Implement at least these three; `least-connections` is the sensible default: * **`least-connections`** — the real default, and the most involved. Every Chronicle server exposes two *anonymous* endpoints (anonymous because a client needs to ask before it has authenticated): `GET /connections/count` reports how many clients are connected to that specific server, and `POST /connections/reserve` reserves a slot ahead of actually connecting. Probe every candidate in parallel, pick the lowest count (breaking ties randomly, not by list order), then reserve a slot on the winner before starting the real connect handshake. A server that doesn’t answer within a short timeout (2 seconds is what the .NET client uses) is treated as maximally loaded rather than failing selection outright, so a restarting or briefly unreachable server just gets routed around. Add a small random jitter (up to 250ms) before every probe, not only the first, so that a fleet of clients starting or reconnecting together doesn’t send synchronized probe bursts. The reservation itself expires on its own after a short window (30 seconds) if it never converts into a real connection, so an abandoned attempt doesn’t permanently inflate a server’s reported count. * **`round-robin`** — cycle through the candidate list in order, but start each client instance at a random offset. Without the random start, a fleet of identical client instances all begin at index zero and stay in lockstep on the same server. * **`random`** — pick uniformly at random on every connect. The simplest strategy, and a reasonable first one to ship while `least-connections` is still being built. The full mechanics of the probe/reserve dance — including why the reservation exists at all — are written out in detail in [Connection Strings → Server support: How least-connections picks a server](/chronicle/connection-strings/server/#how-least-connections-picks-a-server). A new client’s `least-connections` implementation should match that behavior, not just the name. ## The wire-compatibility handshake Before a connection is usable, the client and server need to agree they’re speaking the same contract — not just the same protocol version, but the same set of services, messages, and fields. Chronicle does this with an actual structural diff, not a version-number check: Every contracts package — .NET’s included — carries a compiled `FileDescriptorSet` (`chronicle.desc` in the non-.NET packages) generated from the exact same `.proto` files the server was built from. On connect, before treating the connection as usable, the client sends its descriptor set to the server, which parses it and compares it structurally against its own: removed services or methods, changed method signatures or streaming shape, removed messages, and fields or enum values that were removed, retyped, relabeled, or renamed (fields are matched by their protobuf field *number*, not name, so a rename is detected even though the wire format itself wouldn’t break). **Pure additions are never reported as incompatible** — a client is only broken by things it can no longer find, not by things the server has grown since the client shipped. If the server reports any incompatibility, the client should raise a hard error and refuse to proceed — better a clear “your client’s contract doesn’t match this server” failure at connect time than a stream of confusing per-call errors later. Against an older server that doesn’t support the compatibility check at all, fall back to fetching its descriptor set and comparing on the client side instead — don’t skip the check entirely just because the newer RPC isn’t there. This is precisely why the contracts package matters so much: your client’s copy of `chronicle.desc` *is* the thing being checked. A hand-generated or stale set of bindings doesn’t just risk subtly wrong types — it risks a hard rejection at connect time, which is the system working as intended. The comparison itself runs on the server, so it is only as current as the kernel build answering it. A kernel that has not been redeployed since a wire-compatibility defect was fixed in the check still computes the old, incorrect verdict — no client-side logic can make it compute a different one. For that situation specifically, give your client a deliberate, opt-in escape hatch (a connection-string option and a matching client option, following the same shape as your TLS-validation skip) that lets a caller bypass the check as a known, temporary trade-off, not a default. Document it as hiding *every* incompatibility the check would have caught, not just the one motivating the override — see the .NET client’s `skipCompatibilityCheck` for the reference shape. ## Staying connected A gRPC channel doesn’t tell you when the server it’s pointed at goes away — TCP can stay technically open on a server that’s stopped responding. Chronicle handles this with a server-pushed keep-alive stream and a client-side watchdog: * The server pushes a keep-alive on an open stream at a steady interval. The client just needs to notice one arriving. * The watchdog checks, on a short fixed interval (roughly once a second is enough), whether a keep-alive has arrived recently (a threshold of a few seconds — five is a reasonable default). If not, treat the session as dropped immediately rather than waiting for an RPC to fail. * On a dropped session, reconnect with **exponential backoff, capped** — doubling the wait each attempt starting from around a second, up to a ceiling around 30 seconds — rather than hammering a server that’s still coming back up. A cancellation should stop the loop outright rather than be treated as a failed attempt to back off from. * The reconnect path is the *same* connect path described above: re-resolve addresses (DNS may have changed), re-run load-balancer selection (the server that dropped isn’t guaranteed to be the one to reconnect to), and re-run the compatibility check. ## What a new client needs to implement * Parse both connection-string forms (explicit host list, `+srv`) and re-resolve on every connect. * Ship at least `least-connections` (matching the probe/reserve protocol) and `round-robin`. * Embed the compiled descriptor set from your contracts package and run the compatibility check before treating a connection as usable; fail hard, don’t degrade silently, on a real mismatch. * Run a keep-alive watchdog with capped exponential backoff, reusing the same connect path for reconnects that the initial connect uses. Next: [Connection string elements](/chronicle/building-a-client/connection-string-elements/) covers the object model most clients wrap around the string itself. # Connection string elements > The Chronicle connection string grammar a client must implement: scheme, hosts, credentials, and every query parameter. [Connection Strings → Server support](/chronicle/connection-strings/server/) is the grammar reference — the scheme, every query parameter, and what each one means. Don’t re-derive that grammar from scratch or from this page; treat it as the spec. This page is narrower: the object model a client SDK typically wraps around that grammar, using the .NET client’s `ChronicleConnectionString` and `ChronicleConnectionStringBuilder` as the reference shape, since that’s the most complete implementation that exists today. ## Parse into a value, don’t pass strings around Every client so far parses the connection string once, into a typed value, rather than re-parsing or grep-ing the raw string wherever a setting is needed. The .NET client’s parsed shape exposes: | Property | Comes from | | --- | --- | | `ServerAddress` / `ServerAddresses` | the host list (single value / full list) | | `IsSrv` | whether the `+srv` scheme was used | | `LoadBalancer` | the `loadBalancer` parameter, defaulting to `least-connections` | | `SrvNameServer` | the `srvNameServer` parameter, for `+srv` lookups | | `Username` / `Password` | the credentials in the authority section | | `AuthenticationMode` | **computed**, not stored — derived from which credentials are present | | `ApiKey` | the `apiKey` parameter | | `SkipTlsValidation` | the `skipTlsValidation` parameter | | `CertificatePath` / `CertificatePassword` | client certificate options | `AuthenticationMode` being computed rather than stored is worth copying deliberately: it means there’s exactly one place that can get the “both credentials and API key present” or “neither present” cases wrong, and it can never drift out of sync with the fields it’s derived from. ## Offer a builder, not string concatenation Constructing a connection string by hand — string-formatting a host, a port, and a pile of query parameters — is exactly the kind of thing that quietly produces an invalid or ambiguous string (wrong separator, double `?`, an unescaped credential). A fluent builder that knows the grammar is worth offering alongside the parser. The shape worth matching — the .NET client’s own builder, used to construct a connection string from discrete settings: * C# ```csharp using Cratis.Chronicle.Connections; public static class ConnectionStringsFluentBuilder { public static ChronicleClient Create() { var connectionString = new ChronicleConnectionStringBuilder() .WithHost("server.example.com") .WithPort(35000) .WithCredentials("clientId", "clientSecret") .Build(); var options = ChronicleOptions.FromConnectionString(connectionString); return new ChronicleClient(options); } } ``` * Kotlin ```kotlin import io.cratis.chronicle.ChronicleClient import io.cratis.chronicle.ChronicleOptions import io.cratis.chronicle.connection.ChronicleConnectionString import io.cratis.chronicle.connection.ChronicleServerAddress // Kotlin has no separate connection string builder type — a data class with named constructor // arguments already gives every property a name, so ChronicleConnectionString is constructed // directly rather than through a fluent builder. fun createClientWithExplicitConnectionString(): ChronicleClient { val connectionString = ChronicleConnectionString( addresses = listOf(ChronicleServerAddress("server.example.com", 35000)), username = "clientId", password = System.getenv("CHRONICLE_CLIENT_SECRET") ?: error("CHRONICLE_CLIENT_SECRET is required"), skipTlsValidation = false ) return ChronicleClient(ChronicleOptions(connectionString)) } ``` * Java ```java import io.cratis.chronicle.ChronicleOptions; import io.cratis.chronicle.connection.ChronicleConnectionString; import io.cratis.chronicle.connection.ChronicleServerAddress; import io.cratis.chronicle.java.BlockingChronicleClient; import java.util.List; // Java has no separate connection string builder type either — ChronicleConnectionString is a // Kotlin data class, so its full constructor (with every property named) is called directly. class ConnectionStringsFluentBuilder { BlockingChronicleClient create() { ChronicleConnectionString connectionString = new ChronicleConnectionString( List.of(new ChronicleServerAddress("server.example.com", 35000)), "clientId", java.util.Objects.requireNonNull(System.getenv("CHRONICLE_CLIENT_SECRET"), "CHRONICLE_CLIENT_SECRET is required"), false, false, null, io.cratis.chronicle.connection.LoadBalancer.LEAST_CONNECTIONS, null, false); return BlockingChronicleClient.connect(new ChronicleOptions(connectionString)); } } ``` * Elixir Build a connection string step by step ```elixir defmodule MyApp.ConnectionStringsFluentBuilder do @moduledoc false alias Chronicle.Connections.ConnectionString def connection_string do "chronicle://server.example.com:35000" |> ConnectionString.parse() |> ConnectionString.with_credentials("clientId", "clientSecret") |> ConnectionString.format() end def start_link do children = [ {Chronicle.Client, connection_string: connection_string()} ] Supervisor.start_link(children, strategy: :one_for_one) end end ``` * TypeScript ```text TypeScript does not support this workflow yet. ``` with equivalents for `WithServerAddresses(...)` (multiple hosts), `WithDevelopmentCredentials()`, `WithApiKey(...)`, `WithoutAuthentication()` (`auth=none`), `WithTlsValidationSkipped()`, and `WithCertificate(path, password)`. A new client’s `Build()` should validate as it assembles — the same “credentials and API key together is an error” rule the parser enforces should be enforced there too, at construction time rather than at first-connect time. The .NET builder does not do this yet: it reports the conflict only when the authentication mode is first read. ## Never log the unredacted form A connection string carries the client secret, and possibly an API key or a certificate password — in full. `ToString()` (or whatever renders it back to a string) should render everything, because something in the client genuinely needs the real value to connect with. But that real value should never end up in a log line or an error message as-is. Offer a second rendering — the .NET client calls it `Redacted` — that keeps everything a log entry needs to be useful (scheme, host, port, non-sensitive options) and replaces every credential with a fixed placeholder. Use that form, specifically, everywhere the client itself logs the connection string — connecting, reconnecting, reporting a connection failure. This is not a theoretical concern: the .NET client’s own log messages used to write the unredacted string on every connect, and the fix was exactly to route those call sites through `Redacted` instead. A new client should get this right from its first log statement rather than patch it in later. ## Building blocks worth keeping separate Two things are easy to conflate but should stay as distinct concepts in the object model: * **The load-balancer strategy name** (`loadBalancer=round-robin`) selects *which* built-in strategy runs. It should also be overridable programmatically with a custom strategy implementation for callers who need one the built-ins don’t cover — don’t make the string parameter the only way in. * **TLS validation** (`skipTlsValidation`) is a connection-string concern, but certificate configuration for a client presenting its *own* certificate (`certificatePath`, `certificatePassword`) is a related but separate concern from validating the *server’s* certificate. Keep them as separate settings even though they both live under “TLS” in the query string. See the full parameter table in [Connection Strings → Server support](/chronicle/connection-strings/server/#query-parameters) for every name, type, and example. Next: [Documentation and snippets](/chronicle/building-a-client/documentation-and-snippets/) covers the last thing a new client needs before it’s ready for other people to use. # Documentation and snippets > How to shape a new Chronicle client's Documentation folder, snippets and CI validator so it plugs into the shared Chronicle docs site. Chronicle’s docs are published as one site, aggregated at build time from a `Documentation/` folder in each product repo — including every client repo. A new client needs to shape its `Documentation/` folder the same way from day one, because the shared Chronicle pages actively rely on every participating client having one. This page is an orientation, not the full mechanics — those are already written down in [Contributing to Clients](/chronicle/contributing/clients/#shared-documentation-snippets). Read that page for the complete rules on snippet IDs, CI validation, and adding a client to the shared tab system. What follows is the shape to start from. ## The standard shape ```text Documentation/ ├── toc.yml ├── index.md landing page for this client's docs ├── getting-started.md ├── client-snippets/** source-only — never becomes a public page └── install, connection setup, framework integration, troubleshooting ``` `Documentation/client-snippets/**` is the important one to get right early: it’s not a docs page at all, it’s the source for the language-specific code shown in the *shared* Chronicle docs. A shared page says: ```mdx ``` and the site looks for a matching snippet ID under every registered client’s snippet root — your client shows up in that tab group the moment `Documentation/client-snippets/events/appending/example.md` exists, with a single fenced code block in your language. No other wiring makes a client opt-in; the file’s presence is the whole mechanism. ## Why concepts live in the shared docs, not in your repo Resist the pull to write your own “Events” or “Projections” page inside your client’s docs. Those concepts are the same across every client — what differs is only the code that expresses them. A client-specific page is for things that are genuinely different per client: installation, how a connection gets configured in your language’s idioms, framework/hosting integration, decorators or annotations, and troubleshooting. Everything else belongs in the shared Chronicle pages, with your code shown through a snippet. If your client doesn’t support a workflow yet, that fact belongs *in the snippet*, not as prose on the shared page — an explicit “this client doesn’t support this yet” snippet keeps the claim next to the code, where whoever adds the capability is already looking, instead of rotting silently in a shared page nobody remembers to update. ## Keep a validator in CI Every existing client repo runs a snippet validator (`Documentation/validate-client-snippets.py` in today’s clients) that compiles every snippet against the real client source, wired into a CI workflow that triggers on changes to either `Documentation/client-snippets/**` or `Source/**`. This is what stops a client API change from silently leaving the shared docs’ code examples broken — without it, a snippet is just a string nobody checks. Build the equivalent for your language before your first snippet ships, not after the first drift is reported. ## Two parallel trees, if your artifact serves two languages Chronicle.Kotlin serves both Kotlin and Java from a single published artifact, and its `Documentation/` folder reflects that directly: `client-snippets/` for Kotlin and a fully parallel `client-snippets-java/` for Java, with every subfolder mirrored between the two. If your client does the same — one package, two idiomatic surfaces — plan for two snippet trees from the start rather than retrofitting a second one later. ## Getting a new client wired into the published site Once your `Documentation/` folder exists, getting it into the published site alongside the other four clients is a short, concrete checklist — see [Contributing to Clients → Add another Chronicle client](/chronicle/contributing/clients/#add-another-chronicle-client) for the exact steps (registering the repo, adding it to the client-docs config, wiring the sync workflow). That’s also the point to talk to the Cratis team again if you haven’t already — getting a new client’s docs live on the site is a one-time setup they can do quickly. # Empty results and query failures > How a Chronicle client should normalize empty collection results and distinguish them from query failures at the contract boundary. A successful collection query with no matches returns an empty collection, not `null`. The same rule applies to non-nullable collection members inside response messages. Client code should be able to enumerate those collections without a null check. ## Normalize at the contract boundary Protobuf does not encode a distinction between a missing repeated field and an empty one. Your generated contracts and result envelopes must expose both as empty collections. Do not add separate null fallbacks to every query or converter: that leaves each new API responsible for rediscovering the same transport behavior. Chronicle’s .NET query and command envelopes share empty-payload creation. The contract generator initializes collection members, and a contract-wide specification checks both constructed messages and messages deserialized from empty payloads. Legacy, non-generated messages must satisfy the same specification. Collection defaults are independent per response. They must also support population by the serializer: an enumerable interface backed by an immutable empty array can fail when the deserializer tries to add received elements. ## Empty does not mean successful Inspect the query result’s success, authorization, validation, and exception information before interpreting its data. A failed query can carry an initialized empty payload; that payload must not turn the failure into a successful query with no matches. Likewise, an initialized single-entity response object does not prove that an entity exists. Honor the lookup API’s documented absence semantics. This collection rule does not replace meaningful nullable scalar metadata, such as an optional state unavailable from an older server. ## Verify more than the empty case Verify empty and populated responses, nested collections, and failed operations. Populated values must survive serialization unchanged, and mutating one response’s collection must not change another response. A contract change that only passes the empty case can still break every response containing data. # Layering an idiomatic client > Why a Chronicle client splits a generated contracts layer from an optional idiomatic layer, and how to structure each when building a new client. A generated contracts package is not something you’d want to hand a developer and call it a day. It’s a faithful mechanical translation of the wire format — service interfaces, message types, `int64` fields as `bigint`, `Guid` as whatever protobuf-net’s BCL extensions say a `Guid` is. It has no decorators, no fluent builder, no idea what a “read model” or a “reactor” is. That’s by design: it’s generated on every kernel release, so nothing generated can carry hand-written idiomatic API surface without it being overwritten the next time. The idiomatic client is a second, separate thing built *on top of* the contracts package, hand written and hand maintained, with its own release cadence. This split — raw contracts underneath, idiomatic API on top — is the one structural decision every Chronicle client has made so far, independent of language. ``` Hand-maintained, its own release cadence Generated — tracks the kernel's release cadence Contracts package @cratis/chronicle.contracts · io.cratis:chronicle-contracts · cratis_chronicle_contracts Idiomatic client @cratis/chronicle · io.cratis:chronicle · cratis_chronicle Optional: in-process testing module io.cratis:chronicle-testing Optional: convenience / hosting package Cratis.Chronicle.AspNetCore · io.cratis:chronicle-spring-boot-starter ``` ## Why the split exists * **The generated layer can’t carry taste.** Attribute-based artifact discovery, fluent builders, a `ChronicleClient` → `EventStore` → `EventLog` API shape — none of that survives regeneration. It has to live somewhere the generator never touches. * **The two layers version independently.** The contracts package tracks the kernel’s release number; the idiomatic client tracks its own API stability. Chronicle.Kotlin, for example, is presently at `2.1.1` while the `io.cratis:chronicle-contracts` package it depends on tracks the kernel’s `16.x` version — two numbers, two meanings, on purpose. * **The value contract, not the whole API, is what has to agree across languages.** Only what crosses the wire — how a `Guid`, a date, a duration, a concept, a derived type is serialized — has to be identical everywhere. Everything above that (dependency injection, task scheduling, how a language discovers its own artifacts) is free to be idiomatic. See [The Value Contract](/chronicle/contributing/clients/value-contract/) for the exact, short list of things that must agree, and [Client Types](/chronicle/contributing/clients/client-types/) for how the .NET client’s own project dependency graph reflects this layering internally. ## How C# does it differently — and why that’s not the model to copy C#‘s idiomatic client, `Cratis.Chronicle`, does not depend on a separately published contracts NuGet package the way the other three languages do. Instead, `Contracts.csproj` is referenced directly and packaged as a **runtime-only** dependency: it ships inside the `Cratis.Chronicle` NuGet package under `runtimes/`, invisible to IntelliSense and uncompilable against directly, while the SDK’s own types occupy `lib/`. A build-time step (`GrpcClients`, using `System.Reflection.Emit`) even pre-generates the gRPC client implementations that would otherwise need a compile-time reference to the internalized contracts types. The mechanics are in [Internalization](/chronicle/contributing/clients/internalization/). That’s possible because C# is Chronicle’s own implementation language and NuGet’s `ref`/`lib` split gives it a tool the other ecosystems don’t have as cleanly. It is not a pattern to reach for in a new client. The other three languages hide the seam a simpler way: publish the contracts package on its own, and let the idiomatic client’s package name and its exports be the only thing a developer ever consumes directly. Nobody imports `@cratis/chronicle.contracts` in application code; they import `@cratis/chronicle`, which happens to depend on it. ## The raw client is usable on its own The idiomatic client — on its own, with no convenience package — is a complete, general-purpose client. It’s what you reach for in a console tool, a script, a background worker, or a test project. Every one of the four existing clients treats this as the default entry point — create the client, get an event store, append an event: * C# Program.cs ```csharp using var client = new ChronicleClient(); var eventStore = await client.GetEventStore("ChronicleConsole"); await eventStore.EventLog.Append("some-event-source", new TestEvent("Hello world!")); ``` * Kotlin Main.kt ```kotlin val client = ChronicleClient(ChronicleOptions.development()) val eventStore = client.getEventStore("ChronicleConsole") eventStore.eventLog.append("some-event-source", TestEvent("Hello world!")) ``` * Java Main.java ```java import io.cratis.chronicle.ChronicleOptions; import io.cratis.chronicle.java.BlockingChronicleClient; class Main { void run() { var client = BlockingChronicleClient.connect(ChronicleOptions.development()); var eventStore = client.getEventStore("ChronicleConsole"); eventStore.getEventLog().append("some-event-source", new TestEvent("Hello world!")); } } ``` * Elixir application.ex ```elixir children = [ {Chronicle.Client, connection_string: Chronicle.Connections.ConnectionString.development(), event_store: "chronicle-console", otp_app: :my_app} ] Supervisor.start_link(children, strategy: :one_for_one) ``` Append the event ```elixir alias Chronicle.Connections.Lifecycle # The client registers in the background; appends return {:error, :not_connected} until then. :ok = Lifecycle.wait_until(Lifecycle.name_for(Chronicle.Client), :registered) :ok = Chronicle.append("some-event-source", %MyApp.Events.TestEvent{ message: "Hello world!" }) ``` * TypeScript index.ts ```typescript import 'reflect-metadata'; import { ChronicleClient, ChronicleOptions } from '@cratis/chronicle'; const client = new ChronicleClient(ChronicleOptions.development()); const eventStore = await client.getEventStore('ChronicleConsole'); await eventStore.eventLog.append('some-event-source', new TestEvent('Hello world!')); ``` ## Convenience packages sit above that, and are optional Once the raw client is stable, it’s common — not mandatory — to build a thin package on top that wires the client into a specific hosting environment: registering it in a DI container, resolving configuration from the host’s standard config system, binding request-scoped concerns like tenant or identity to the current HTTP request. C# has done this since early on with `Cratis.Chronicle.AspNetCore`. Kotlin followed the same shape much later, once its idiomatic client’s artifact-discovery machinery existed to build on: `io.cratis:chronicle-spring-boot-starter` provides Spring auto-configuration, `ChronicleProperties`, and per-request namespace resolution (fixed, HTTP-header, subdomain, or authentication-claim based) — the direct JVM analogue of the ASP.NET Core package. TypeScript and Elixir don’t have an equivalent yet (no Express/Fastify/NestJS package for TypeScript, no Phoenix package for Elixir). That’s a genuine gap in those ecosystems today, not a sign the pattern doesn’t apply — build a convenience package when your idiomatic client is stable enough to build one on top of, and there’s an obvious dominant hosting framework in your language to target. A fourth, optional layer is worth knowing about too: an **in-process testing module** (`io.cratis:chronicle-testing` in Kotlin, `Cratis.Chronicle.Testing`/`Cratis.Chronicle.XUnit` in .NET) that depends on the idiomatic client and lets application code exercise slices without a live kernel. It’s the same “build it once the layer beneath is stable” story as the convenience package, just aimed at test authors instead of host authors. Next: [Authentication and bearer tokens](/chronicle/building-a-client/authentication-and-bearer-tokens/) covers the first thing the idiomatic client actually has to do once it opens a connection. # Two ways to start > Two ways to get typed bindings for Chronicle's gRPC services when building a client: generate them from the .proto files or request a generated package. Before writing a line of idiomatic code, a new client needs typed bindings for Chronicle’s gRPC services. There are two ways to get them, and they’re not equally good uses of your time. ## Option A: generate from the `.proto` files yourself Chronicle’s gRPC service and message definitions are code-first: they’re written in C# using [protobuf-net.Grpc](https://github.com/protobuf-net/protobuf-net.Grpc) attributes (`[Service]`, `[Operation]`), and standard `.proto` files are extracted from the compiled contracts assembly by a tool called `ProtoGenerator`. Those extracted files live in `Source/Kernel/Protobuf/*.proto` in the Chronicle repository and are regenerated on every kernel build — see [Protobuf Extraction](/chronicle/contributing/clients/protobuf-extraction/) for exactly how that works. Because they’re standard proto3, any language with a gRPC/protobuf toolchain can generate client bindings from them directly: ```bash protoc \ -I Source/Kernel/Protobuf \ --go_out=. \ --go-grpc_out=. \ Source/Kernel/Protobuf/*.proto ``` This is the fully self-service path. Nobody has to grant you anything — the files are in the repository. It’s also the slower path: you own getting the `protoc` invocation right for your language’s plugin ecosystem, you own re-running it every time the kernel’s contract changes, and you own publishing and versioning the result yourself. ## Option B: ask the Cratis team for a generated package (recommended) The alternative is to ask the Cratis team to turn on the same pipeline that already produces TypeScript, Elixir, and Kotlin’s contracts packages, for your language. Concretely, that pipeline: 1. Extracts `.proto` files from the kernel’s contracts assembly (the step Option A does by hand). 2. Runs your language’s code generator against them. 3. Publishes the result to the registry your ecosystem actually uses — npm, Hex, Maven Central, NuGet, wherever idiomatic packages for that language live. 4. Version-locks the published package to the kernel release it was generated from, and gates the whole release on a wire-compatibility check, so a contracts package can never ship out of step with the server it talks to. This is precisely how it happened for every contracts package that exists today: | Language | Contracts package | Registry | | --- | --- | --- | | TypeScript | `@cratis/chronicle.contracts` | npm | | Kotlin/Java | `io.cratis:chronicle-contracts` | Maven Central | | Elixir | `cratis_chronicle_contracts` | Hex | | Python | `cratis-chronicle-contracts` | PyPI | Python is the newest addition, and it’s worth noticing what stage it’s at: the contracts package exists and publishes on its own, but there’s no idiomatic `Chronicle.Python` repo built on top of it yet. That’s expected — [layering an idiomatic client](/chronicle/building-a-client/layering-an-idiomatic-client/) on top of the contracts package is a separate, later step, not something that has to land in the same change as the contracts package itself. None of these client repos generate or own their own contracts — the packages are built and published from parallel jobs in Chronicle’s own `publish.yml`, all consuming the same `.proto` artifact, all gated behind the same `wire-compatibility` check. It happens fast, too: Elixir’s idiomatic client already depended on a published, versioned contracts package (`{:cratis_chronicle_contracts, ">= 0.1.0"}`) in its very first commit. ### Why this is worth asking for * **It’s fast.** The generator, the packaging conventions, and the publish pipeline already exist and already work for three ecosystems. Adding a fourth is mostly configuration, not invention. * **It stays correct without your attention.** The package versions in lockstep with the kernel and is protected by the wire-compatibility gate — you’re not responsible for noticing that the kernel added a field or renamed a service. * **It’s published where your ecosystem expects it.** A hand-generated set of files sitting in a `vendor/` folder is not something other people in your language’s community can `npm install` or `mix deps.get`. A registry package is. * **It’s the internal team’s day-to-day workflow too.** Asking isn’t a special-case request that routes around some other, more official process — it *is* the process the Cratis team already uses for every language it supports. Reach out to the Cratis team to start that conversation. Once the contracts package exists and is publishing on its own release cadence, move on to [layering an idiomatic client on top of it](/chronicle/building-a-client/layering-an-idiomatic-client/). # Captures > Turn changes in external data into Chronicle events with captures, and what the capturing engine supports today. Captures let you describe Change Data Capture (CDC) pipelines that turn external data changes into Chronicle events. The capturing engine runs a subset of the language Captures parse and compile everything described here, but as of Chronicle 19.6 the engine that runs them supports only part of it: * Only `api` sources are read. A capture with a `webhook` or `message` source never produces events. * Only root-level `append` rules run. `map` operations, `nested` scopes, and `children` scopes are accepted but not applied. * An assignment can take a property of the item (`$.path`) or a quoted literal. `$context`, `$env`, and template expressions are rejected at run time, and so are expression-based `when` conditions. A cycle that hits an unsupported construct fails as a whole and is logged; no event is appended for it. ## Overview A capture definition describes: * **Source**: where data comes from (`api`, `webhook`, `message`) * **Identity key**: the property used to detect changes per entity * **Mapping**: optional transformations before append logic * **Append rules**: which event to append and when to append it * **Scopes**: root, nested objects, and child collections Chronicle supports four authoring approaches. The first is language-neutral — the others are client features, documented alongside the rest of each client: * **Capture Declaration Language (CDL)** for text-based definitions * **Declarative API** (.NET) for fluent definitions in code * **Model-bound API** (.NET) for attribute-based definitions * **Capture DSL** (Elixir) for definitions in code that compile to CDL ## Define and manage a capture in your client In Elixir, a module with `use Chronicle.Captures.Capture` declares the source, the identity key, and the append rules; the DSL compiles to CDL at build time. * Elixir ```elixir defmodule MyApp.Events.CapturesIndexInvoiceStatusChanged do use Chronicle.Events.EventType, id: "InvoiceStatusChanged" defstruct [:status] end defmodule MyApp.Captures.CapturesIndexInvoiceCapture do use Chronicle.Captures.Capture, name: "InvoiceCapture" alias MyApp.Events.CapturesIndexInvoiceStatusChanged api_source("InvoicingApi", route: "/invoices", poll: "10m") key("id") append CapturesIndexInvoiceStatusChanged, when: "status" do set(:status, "$.status") end end ``` The mapping, `nested`, and `children` constructs of the language are available in the DSL too, and are subject to the engine limits described above. * Elixir ```elixir defmodule MyApp.Events.CapturesIndexCustomerChanged do use Chronicle.Events.EventType, id: "CustomerChanged" defstruct [:first_name, :last_name] end defmodule MyApp.Events.CapturesIndexBillingAddressChanged do use Chronicle.Events.EventType, id: "BillingAddressChanged" defstruct [:street] end defmodule MyApp.Events.CapturesIndexItemAdded do use Chronicle.Events.EventType, id: "ItemAdded" defstruct [:line_number] end defmodule MyApp.Captures.CapturesIndexCustomerCapture do use Chronicle.Captures.Capture, name: "CustomerCapture" alias MyApp.Events.{ CapturesIndexBillingAddressChanged, CapturesIndexCustomerChanged, CapturesIndexItemAdded } api_source("CustomersApi", poll: "5m") key("customerId") map do rename(:full_name, :fornavn) translate(:status, :status, [{"aktiv", "active"}, {"inaktiv", "inactive"}]) end append CapturesIndexCustomerChanged, when: {:any, ["firstName", "lastName"]} do set(:first_name, "$.firstName") end nested :billing_address do map do rename(:street, :"fakturaAdresse.gate") end append CapturesIndexBillingAddressChanged, when: "street" do set(:street, "$.street") end end children :line_items, identified_by: :line_number do append CapturesIndexItemAdded, when: :added do set(:line_number, "$.lineNumber") end end end ``` Registering, starting, stopping, listing, validating, and deleting captures, and reading recently captured events, are operations on the client: * Elixir ```elixir defmodule MyApp.CapturesIndexManage do alias MyApp.Captures.CapturesIndexInvoiceCapture def register_and_start do {:ok, _capture} = Chronicle.Captures.register(CapturesIndexInvoiceCapture) :ok = Chronicle.Captures.start(CapturesIndexInvoiceCapture) end def list_captures do Chronicle.Captures.list() end def validate_declaration do Chronicle.Captures.validate(CapturesIndexInvoiceCapture) end def recent_captured_events do Chronicle.Captures.captured_events("InvoiceCapture", max_events: 50) end def stop_and_delete do :ok = Chronicle.Captures.stop(CapturesIndexInvoiceCapture) :ok = Chronicle.Captures.delete(CapturesIndexInvoiceCapture) end end ``` ## Topics | Topic | Description | | --- | --- | | [Capture Declaration Language](/chronicle/captures/capture-declaration-language/) | CDL syntax, semantics, and formal language specification | | [Declarative Captures](/chronicle/clients/dotnet/captures/declarative/) | .NET client fluent API (`ICapturer` + `ICaptureBuilder`) | | [Model-Bound Captures](/chronicle/clients/dotnet/captures/model-bound/) | .NET client attribute-based capture declarations | # Capture Declaration Language > Syntax of the Capture Declaration Language, and which parts the capturing engine runs today. The Capture Declaration Language (CDL) is an indentation-based DSL for defining captures that transform external data changes into Chronicle events. ## Overview CDL definitions compile to `CaptureDefinition` and support: * Source declarations (`api`, `webhook`, `message`, `events`) * Key declaration for identity and diffing * Optional map operations (`translate`, `split`, field rename, template assignment) * Event append rules with `when` conditions * Nested object scopes * Child collection scopes The capturing engine runs a subset of the language Captures parse and compile everything described here, but as of Chronicle 19.6 the engine that runs them supports only part of it: * Only `api` and `events` sources are read. A capture with a `webhook` or `message` source never produces events. * Only root-level `append` rules run. `map` operations, `nested` scopes, and `children` scopes are accepted but not applied. * An assignment can take a property of the item (`$.path`) or a quoted literal. `$context`, `$env`, and template expressions are rejected at run time, and so are expression-based `when` conditions. A cycle that hits an unsupported construct fails as a whole and is logged; no event is appended for it. ## Example This capture polls an API every ten minutes and appends `InvoiceStatusChanged` for each invoice whose `status` changed. It uses only what the engine runs today: ```cdl capture InvoiceCapture source api api InvoicingApi route /invoices poll 10m key id append InvoiceStatusChanged when status status = $.status ``` The full language adds `map` operations, `nested` and `children` scopes, and context expressions. The sections below describe their syntax, which compiles today: ```cdl capture InvoiceCapture source api api InvoicingApi route /invoices poll 10m key id map status = status translate "utkast" => draft "betalt" => paid append InvoiceStatusChanged when status status = $.status changedAt = $context.occurred nested billingAddress append InvoiceBillingAddressChanged when street or city street = $.billingAddress.street children lineItems identified by lineNumber append InvoiceLineItemAdded when added lineNumber = $.lineNumber append InvoiceLineItemRemoved when removed lineNumber = $.lineNumber ``` ## Language elements ### Header * `capture ` defines one capture. ### Source block ```cdl source api|webhook|message|events ... ``` Source properties: * API: `api`, `route`, `poll` * Webhook: `path` * Message: `topic` * Events: `from` (repeatable) For API sources, `api` identifies a configured **[External Service](/chronicle/external-services/)** by name. The External Service holds the base URL and the authentication for the connection. `route` is optional and is appended to that base URL; if omitted, the base URL is used as-is. Note Authentication is **not** part of the CDL. For API sources it is configured on the referenced External Service; for webhook sources it is configured in code on the source builder. Either way, secrets and tokens never live in capture text. See [Configuring authentication](#configuring-authentication) below. ### Events source An `events` source captures from the **public events** of another event store that arrive in this event store’s inbox, and appends **private events** to the local event log. ```cdl capture ShipmentTracking source events from ShipmentDispatched from ShipmentDelivered key $eventSourceId append OrderShipped when status from "pending" to "dispatched" orderId = $.orderId shippedAt = $context.occurred ``` * The inbox is `inbox-`, derived from the origin the `from` event types were registered with. Every type must share one nonempty origin; split events from different origins into separate captures. An event type without an origin must be registered with the `[EventStore]` of its owner. Only an inbox can be captured from, never the event log, the outbox, the system sequence or another private sequence. * `from` names a registered event type to capture. It must be registered as `Public` with an origin; private types and types of unspecified visibility (clients that predate visibility are not accepted) are rejected. Repeat it for each type. The incoming event content is the captured item (`$.path`), and the event context is available as `$context.` (`eventSourceId`, `eventType`, `sequenceNumber`, `occurred`, `correlationId`, `subject`). `key $eventSourceId` keys the capture on the incoming event’s event source. * There is no `poll`. The capture is an observer of the inbox, so it catches up, resumes and fails a partition the way other observers do. * The state per key is what the key’s incoming events have said so far: each event’s properties are laid over the previous state, then compared with it. A `when status from "a" to "b"` rule therefore fires when an event moves `status` between those values. The first event for a key is an `added` change. * Appended event types must be registered, must not be `Public` and must not also be `from` types. A capture appends private events of this event store. * The appended events are a local fact recorded when the capture handled the event. When the incoming event occurred at its origin is kept in the causation (`sourceOccurred`), along with `sourceSequence`, `sourceSequenceNumber`, `sourceEventType` and `sourceEventSourceId`. The correlation id of the incoming event carries over. * Capturing is idempotent. Each appended event is tagged with the incoming event it came from, and an incoming event that already has appended events is not translated again when it is redelivered. * Every namespace of the event store is captured separately, each with its own remembered state. Appends go to the event log of the namespace the incoming event arrived in. * A failure - an event without a key, an append that is rejected - fails the partition rather than skipping the event. * Starting subscribes the capture in every namespace before it is recorded as started. If any namespace fails, the ones already subscribed are unsubscribed again and the start fails. * A started capture stays subscribed: it is subscribed again when the kernel starts, in namespaces added later, and a periodic reconciliation (every minute while the captures manager is active) recovers a subscription lost to a restart, deactivation or failed setup. A failure to subscribe is logged as an error and retried; it is not silent. * Stopping unsubscribes the capture. Deleting also removes its observers, their offsets and definition, and the state it remembered in each namespace. Legacy declarations `sequence inbox-` is deprecated. Existing declarations still compile without rewriting their stored text, and new declarations containing it are accepted too. The named inbox must match the origin of every `from` event type. Chronicle logs a deprecation warning once per declaration per kernel process. Remove the `sequence` line when updating a declaration; use only `from` lines for new captures. ### Configuring authentication **API sources** connect through a configured [External Service](/chronicle/external-services/). Configure the base URL and authentication once on the External Service; the capture simply references it by name — see the [Declarative Captures](/chronicle/clients/dotnet/captures/declarative/) example for what that reference looks like in the .NET client’s builder API. **Webhook sources** are inbound and configure their authentication in code on the source builder: * `WithBasicAuth(username, password)` — basic authentication * `WithBearerToken(token)` — bearer token authentication * `WithOAuth(authority, clientId, clientSecret)` — OAuth authentication When no authentication is configured, the source is treated as unauthenticated. ### Key directive * `key ` ### Map block `map` supports: * field rename: `target = source` * template assignment: “target = \`template ${expr}\`\`\` * translate: `target = source translate` + value entries * split: ```cdl split source by "," first second ``` ### Append block ```cdl append when ... = ``` Supported `when` forms: * `when property` * `when p1 or p2` * `when p1 and p2` * `when property from old to new` * `when added` * `when removed` * `when \`expr“ ### Nested block ```cdl nested [map ...] append ... ``` ### Children block ```cdl children identified by [map ...] append ... ``` ## Expressions Typical source expressions: * `$.path` (current payload) * `$previous.path` (previous payload) * `$context.occurred` (capture context; for an `events` source, the context of the incoming event) * `$eventSourceId` (events sources: the event source of the incoming event) * `$env.VARIABLE` (environment lookup) ## Formal language specification See [Grammar (EBNF)](/chronicle/captures/capture-declaration-language/grammar/) for the full formal syntax. # Grammar (EBNF) > The formal EBNF grammar of the Capture Declaration Language (CDL) for Chronicle captures, with the compiler's semantic validation rules. This document defines the formal grammar for the Capture Declaration Language (CDL). ## EBNF notation | Notation | Meaning | | --- | --- | | `=` | Definition | | `;` | End of rule | | `{ ... }` | Zero or more | | `[ ... ]` | Optional | | `(...)` | Grouping | | `\|` | Alternative | | `"..."` | Literal token | ## Complete grammar ```ebnf Document = { Capture } ; Capture = "capture", Path, NL, [ INDENT, { Directive }, DEDENT ] ; Directive = SourceBlock | KeyDirective | MapBlock | AppendBlock | NestedBlock | ChildrenBlock ; SourceBlock = EventsSource | "source", SourceType, NL, INDENT, { SourceProperty }, DEDENT ; SourceType = "api" | "webhook" | "message" ; SourceProperty = ("api" | "route" | "poll" | "path" | "topic"), LineValue, NL ; EventsSource = "source", "events", NL, INDENT, FromEvent, { FromEvent }, DEDENT ; FromEvent = "from", LineValue, NL ; KeyDirective = "key", LineValue, NL ; MapBlock = "map", NL, INDENT, { MapOperation }, DEDENT ; MapOperation = SplitOperation | RenameOrTemplateOrTranslate ; RenameOrTemplateOrTranslate = Identifier, "=", LineValue, [ "translate", NL, INDENT, { TranslateEntry }, DEDENT ], NL ; TranslateEntry = SimpleValue, "=>", SimpleValue, NL ; SplitOperation = "split", Path, "by", StringLiteral, NL, INDENT, { Path, NL }, DEDENT ; AppendBlock = "append", Path, NL, INDENT, "when", WhenExpression, NL, { Assignment }, DEDENT ; WhenExpression = "added" | "removed" | TemplateLiteral | Path, [ "from", SimpleValue, "to", SimpleValue | { "or", Path } | { "and", Path } ] ; Assignment = Identifier, "=", LineValue, NL ; NestedBlock = "nested", Path, NL, INDENT, [ MapBlock ], { AppendBlock }, DEDENT ; ChildrenBlock = "children", Path, "identified", "by", Path, NL, INDENT, [ MapBlock ], { AppendBlock }, DEDENT ; Path = Identifier, { ".", Identifier } ; LineValue = { TokenExceptNewline } ; SimpleValue = StringLiteral | NumberLiteral | "true" | "false" | "null" | Identifier ; Identifier = Letter, { Letter | Digit | "_" } ; ``` ## Semantic constraints The parser grammar is complemented by compiler validation rules: * Exactly one `source` block is required * Exactly one `key` directive is required * At most one root `map` block is allowed * Every `append` block must contain one `when` clause * Every `from` event in an events source must be registered as public with the same nonempty origin; Chronicle derives the inbox from that origin Legacy `sequence inbox-` lines in an events source are deprecated but still accepted, with a deprecation warning. The named inbox must match every event’s origin. They are omitted from the grammar for new declarations above. # Client SDKs > Install and connect a Chronicle client SDK in your language, and find where each client's installation, setup and API details live. Chronicle concepts, guides, and feature workflows live in the shared Chronicle docs and use language tabs when the client APIs differ. Use the client SDK sections for installation, connection setup, runtime integration, language idioms, and API reference details. ## Shared Chronicle topics * [Get started](/chronicle/get-started/) * [Events and event logs](/chronicle/events/) * [Event appending](/chronicle/events/appending/) * [Read models](/chronicle/read-models/) * [Projections](/chronicle/projections/) * [Reactors](/chronicle/reactors/) * [Reducers](/chronicle/reducers/) * [Constraints](/chronicle/constraints/) * [Event seeding](/chronicle/event-seeding/) * [Compliance](/chronicle/compliance/) * [Transactions and unit of work](/chronicle/events/transactions/) * [Event evolution](/chronicle/understanding-event-evolution/) ## Client SDK details * [.NET client](/chronicle/clients/dotnet/) * [Kotlin client](/chronicle/clients/kotlin/) * [Elixir client](/chronicle/clients/elixir/) * [TypeScript client](/chronicle/clients/typescript/) # .NET client > Use the .NET client when your Chronicle application runs on C# or another .NET language. The .NET client is the C#/.NET SDK for Chronicle. It gives .NET applications typed access to event stores, event logs, read models, projections, reducers, reactors, constraints, migrations, namespaces, and hosting integration. Use this section for .NET-specific setup and APIs. The main Chronicle docs explain the event-sourcing concepts once and use language tabs for examples that apply across clients. ## When to use it Use the .NET client when you are building: * a console tool or script that talks directly to Chronicle * an ASP.NET Core service that appends events from endpoints or Arc commands * a Worker Service that runs projections, reducers, reactors, or background append logic * a .NET test project that uses Chronicle’s in-process testing scenarios ## Install Install the .NET packages that match the host style you are building. The templates do this for you, but explicit package references are useful when adding Chronicle to an existing application. ```bash dotnet add package Cratis.Chronicle dotnet add package Cratis.Chronicle.AspNetCore ``` ## Shared Chronicle topics * [Get started](/chronicle/get-started/) * [Events and event logs](/chronicle/events/) * [Appending events](/chronicle/events/appending/) * [Read models](/chronicle/read-models/) * [Projections](/chronicle/projections/) * [Reactors](/chronicle/reactors/) * [Reducers](/chronicle/reducers/) * [Constraints](/chronicle/constraints/) ## .NET-specific pages * [Health checks](/chronicle/clients/dotnet/health-checks/) * [Explicit artifact registration](/chronicle/clients/dotnet/explicit-artifacts/) * [Console quickstart](/chronicle/get-started/console/) * [ASP.NET Core hosting](/chronicle/get-started/aspnetcore/) * [Worker Service hosting](/chronicle/get-started/worker/) * [.NET connection strings](/chronicle/connection-strings/dotnet-client/) * [.NET namespace resolution](/chronicle/namespaces/dotnet-client/) * [.NET event type migrations](/chronicle/migrations/dotnet-client/) * [Seeding events with C#](/chronicle/event-seeding/seeding-with-csharp/) ## Shared examples When a Chronicle concept applies to every client, keep the prose in the shared Chronicle page and put the C# code in `Chronicle/Documentation/client-snippets/**`. The site renders it beside Kotlin, Elixir, and TypeScript snippets in synchronized language tabs. # Authoring captures in .NET > The two .NET-only ways to author Chronicle captures in code, model-bound attributes and the fluent builder, plus the language-neutral CDL. [Captures](/chronicle/captures/) turn external data changes into Chronicle events. Chronicle supports three ways to author a capture, and only one of them is language-neutral: | Approach | Language | Where it’s documented | | --- | --- | --- | | Capture Declaration Language (CDL) | Text-based, any client | [Capture Declaration Language](/chronicle/captures/capture-declaration-language/) | | Declarative API | C# fluent builder | [Declarative Captures](/chronicle/clients/dotnet/captures/declarative/) | | Model-bound API | C# attributes | [Model-Bound Captures](/chronicle/clients/dotnet/captures/model-bound/) | The declarative and model-bound APIs are .NET client features — they compile to the same `CaptureDefinition` a CDL text file would, but the authoring surface itself is C#-specific. # Declarative captures > Fluent C# builder API for defining CDC captures in code — ICapturer, ICaptureBuilder, source configuration, mapping, and append rules. Declarative captures use fluent C# builder APIs to define CDC behavior in code. ## Entry points * Implement `ICapturer` * Configure the capture in `Define(ICaptureBuilder builder)` ```csharp public class InvoiceCapture : ICapturer { public void Define(ICaptureBuilder builder) => builder .FromApi("InvoicingApi", _ => _ .OnRoute("/invoices") .PollEvery("10m")) .Key("id") .Append(_ => _ .WhenPropertyChanges("status") .Set(e => e.Status, "$.status") .Set(e => e.ChangedAt, "$context.occurred")); } ``` ## Source configuration * `FromApi(api, configure?)` * `FromWebhook(path, configure?)` * `FromMessageTopic(topic)` API source options: * `OnRoute(route)` * `PollEvery(interval)` `FromApi(api, ...)` references a configured [External Service](/chronicle/external-services/) by name. The External Service holds the base URL and authentication, so no authentication is configured on the API source. If no route is configured, the External Service base URL is used directly. Webhook source options (webhook sources are inbound and configure their own authentication): * `WithBasicAuth(username, password)` * `WithBearerToken(token)` * `WithOAuth(authority, clientId, clientSecret)` Authentication is never part of the capture declaration text, so that secrets and tokens do not live in capture definitions. ## Defining identity Use `Key(propertyPath)` to define the identity property used when comparing current vs. previous payload. ## Mapping operations Use `Map(...)` on root, nested, or children scopes: * `Rename(source, target)` * `Template(target, template)` * `Translate(target, source, entries => ...)` * `Split(source, separator, targets...)` ## Append rules Use `Append(...)` with one condition and one or more field assignments: Condition methods: * `WhenPropertyChanges(property)` * `WhenAnyOf(properties...)` * `WhenAllOf(properties...)` * `WhenTransition(property, from, to)` * `WhenAdded()` * `WhenRemoved()` * `WhenExpression(expression)` Assignment method: * `Set(targetPropertyExpression, sourceExpression)` ## Scoped capture sections * `Nested(objectPath, configure)` for nested object rules * `Children(collectionPath, identifiedBy, configure)` for child collection rules Both support local `Map(...)` and `Append(...)` blocks. ## Optional capture metadata You can set an explicit capture ID using `[Capture("guid")]` on the `ICapturer` type. # Model-bound captures > Attribute-based capture declarations in the .NET client — source, key, append conditions, and field mappings declared with attributes. Model-bound captures use attributes to declare source, key, append conditions, and field mappings. ## Capture source and key Apply one source attribute and one key attribute on the capture type: * `[ApiCapture(api, Poll = ..., Route = ...)]` * `[WebhookCapture(path)]` * `[MessageCapture(topic)]` * `[CaptureKey(property)]` ```csharp [ApiCapture("InvoicingApi", Poll = "10m", Route = "/invoices")] [CaptureKey("id")] public class InvoiceCapture; ``` `ApiCapture` references a named API configuration. If `Route` is not set, the configured API base URL is used directly. Note Authentication is not declared on the capture attribute. It is configured in code where the source is configured, so that secrets never live in the capture declaration. See [Configuring authentication](/chronicle/captures/capture-declaration-language/#configuring-authentication). ## Append conditions on event types Use one condition attribute on each event type: * `[WhenPropertyChanged(property)]` * `[WhenAnyOf(properties...)]` * `[WhenAllOf(properties...)]` * `[WhenTransition(property, from, to)]` * `[WhenAdded]` * `[WhenRemoved]` ```csharp [WhenPropertyChanged("status")] public record InvoiceStatusChanged( [MapFrom("$.status")] string Status, [MapFromContext("occurred")] DateTimeOffset ChangedAt); ``` ## Field mapping attributes Use on event properties (or primary-constructor parameters): * `[MapFrom(sourcePath)]` maps from capture payload values * `[MapFromContext(contextProperty)]` maps from capture runtime context ## When to use model-bound captures Use this approach when: * Capture intent should live close to event contracts * Mapping is mostly direct and attribute-friendly * You want concise definitions with minimal fluent code # Discovery report and identity drift > Inspect discovered .NET client artifacts and configure diagnostics for likely identity changes before registration. Use `IEventStore.Discovery` to inspect what the .NET client found, and `Registration.IdentityDrift` to inspect likely renames before registration. These are diagnostics, not proof that every artifact registered successfully; use the [registration outcome](/chronicle/clients/dotnet/registration-outcome/) for that. ## Inspect discovered artifacts `Discovery` is an immutable snapshot of the last completed `DiscoverAll()` pass. Reading it does not start discovery. Before discovery runs, it equals `DiscoveryReport.NotRun`: `HasRun` is false and `Artifacts` is empty. Each `DiscoveredArtifact` has a `Kind`, string `Id`, and nullable `ClrType`. The kinds are `EventType`, `Constraint`, `Reactor`, `Reducer`, `Projection`, and `ReadModel`. Read model ids are container names. Constraints and delegate reactors may have no CLR type. Failed projection builds do not appear as discovered handlers; their failures belong to `Registration.Artifacts`. Given an `IEventStore` named `eventStore`: ```csharp using Cratis.Chronicle.Discovery; var report = eventStore.Discovery; var reactorCount = report.CountOf(ArtifactKind.Reactor); var reactors = report.OfKind(ArtifactKind.Reactor); ``` Explicit or runtime registrations added later do not mutate this snapshot. The identity check builds a fresh view for each foreground registration run, including those additions. Background retries reuse that run’s findings without repeating snapshots or drift logs. Webhooks, external subscriptions, and read model reactors are outside the report. ## Inspect likely identity changes Before registering, the client compares its artifacts with the kernel’s stored event types and observers for the event store and namespace. Each finding records `Kind`, `PreviousId`, and `CurrentId`: ```csharp await eventStore.RegisterAll(); var findings = eventStore.Registration.IdentityDrift; ``` An observer candidate must have the same kind and a matching short name, and be absent from the current declarations of that kind. Reactors and reducers must no longer be subscribed. Projections skip subscription confirmation: the kernel keeps the old projection subscribed until registration retires it. An event type candidate must be client-owned and uniquely match a nonempty coarse schema shape. A current id already stored in the kernel is not reported again on reconnect. ## Choose a policy Set `ChronicleOptions.ArtifactIdentityDrift` to a value from `ArtifactIdentityDriftPolicy`: | Policy | Behavior | | --- | --- | | `Warn` (default) | Log findings, include them in `Registration.IdentityDrift`, and continue registering. | | `Fail` | Throw `ArtifactIdentityDriftDetected` before registering any artifacts when Warning-level reactor or event type findings exist. Information-level reducer and projection findings never fail registration. `Registration.Failure` records the failure and `IdentityDrift` retains all findings. Bounded and background retries do not retry this failure. | | `Ignore` | Skip the identity check and its kernel calls. Discovery reporting still runs. | Snapshot failures never block registration, even with `Fail`. The client logs the skipped check and returns no findings. A failed subscription confirmation drops that reactor or reducer candidate. Diagnostic calls share a five-second deadline. `Fail` is useful in CI or staging, not a guarantee that every rename will be caught. Review each finding before acting: pin the previous reactor id to preserve its position, or deliberately remove an abandoned observer. See [Reactor identity and renaming](https://github.com/Cratis/Chronicle/blob/main/Documentation/reactors/identity.mdx) and [the configuration reference](https://github.com/Cratis/Chronicle/blob/main/Documentation/configuration/chronicle-options.mdx#artifactidentitydrift). ## Log levels | Level | Message | | --- | --- | | Information | Discovery totals per kind, event store, and namespace. | | Debug | Discovered ids per kind. | | Warning | Likely reactor rename: catch-up re-runs side effects; pin the old id or remove the orphan. | | Information | Likely reducer or projection rename: the read model rebuilds; the kernel also retires renamed projections. | | Warning | Likely event type rename: stored history remains under the old id. | | Warning | Snapshot unavailable: the check is skipped and registration continues. | | Debug | Subscription confirmation or report building failed: the diagnostic is omitted. | ## Limits of the heuristic * During a rolling deployment, a still-subscribed old reactor or reducer is filtered out. Once the new id is registered, later checks stay quiet, so this can miss a rename. * Two services sharing a store and a short type name can look like a rename when one is down. Projections can match even while subscribed, so same-named projections from separate services are more prone to false positives. Reducer and projection findings remain informational under every policy. Review the evidence; there is no per-id allow-list. * Same-shaped events can match coincidentally. Empty and ambiguous shapes are excluded, but a rename accompanied by schema changes can be missed. * Event types cannot be removed to acknowledge a finding under `Fail`. Preserve the old id when evolving its contract, or switch to `Warn` after reviewing an intentional new identity. See [Event type migrations](https://github.com/Cratis/Chronicle/blob/main/Documentation/concepts/event-type-migrations.mdx). # Event sources in the .NET client > The .NET client types for event source definitions, with links to the guides in the event sources section. This page covers the .NET client’s API for event source definitions. The guides, the scenarios and the explanation of how they work are in [Event sources and streams](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/index.mdx). This page lists the client types and where each is explained. An event source is optional: the existing append APIs remain valid and append events without a definition. ## Types | Type | Purpose | Explained in | | --- | --- | --- | | `IEventSource` | Marker interface. A class that implements it is a definition | [Define an event source](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/defining.mdx) | | `[EventSource]` | Names the definition and sets its description and default `Concurrency` | [Define an event source](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/defining.mdx#declare-the-definition) | | `[EventStream]` | Declares a stream, with its own description and `Concurrency`. Repeat it for each stream | [Define an event source](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/defining.mdx#declare-the-definition) | | `ConcurrencyDimensions` | Flags that choose which values take part in an append’s concurrency check | [Concurrency](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/concurrency.mdx) | | `Append`, `AppendMany`, `AppendPrepared` | Extension methods on `IEventSequence` | [Append through a definition](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/appending.mdx) | | `EventForEventSourceId.EventSource` and `.EventStream` | Route one event of a multi-source batch through a definition | [Append through a definition](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/appending.mdx#append-for-several-event-sources) | | `ConcurrencyScopeBuilder.ForEventSource` | Builds an explicit scope from a definition’s names | [Concurrency](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/concurrency.mdx#build-an-explicit-scope) | | `[FromEventSource]` | Filters a reactor or reducer to one stream | [Filter observers](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/filtering.mdx) | | `IEventStore.EventSources` and `IEventSources` | `All`, `GetFor(Type)` and `GetFor(string)` return the discovered `EventSourceDefinition` records | [Define an event source](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/defining.mdx#check-what-is-registered) | | `IClientArtifactsProvider.EventSources` | The types the client treats as definitions | [How it works](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/how-it-works.mdx#discovery-and-registration) | ## Exceptions | Exception | Thrown when | | --- | --- | | `DuplicateEventSourceName` | Two definitions share a name | | `DuplicateEventStreamName` | A definition declares a stream twice | | `UnknownEventSource` | A type or name is not a discovered definition | | `EventStreamDoesNotBelongToEventSource` | A stream name is not declared by the definition | | `EventRoutingContradictsEventSource` | An explicit event source type or stream type contradicts the definition | | `IncompatibleConcurrencyScopesForEventSource` | A batch needs different guards for one event source id. See [Concurrency](https://github.com/Cratis/Chronicle/blob/main/Documentation/event-sources/concurrency.mdx#a-batch-that-needs-two-guards-for-one-event-source-id) | | `EventSourcesNotSupported` | An `IEventStore` or `IEventSequence` implementation does not support event sources, such as a custom test double | ## Explicit registration With [explicit artifact registration](/chronicle/clients/dotnet/explicit-artifacts/) you nominate the definitions in the composition root. A nominated event source keeps its declared streams and metadata, and an explicit name changes the registered identity only. ## Related * [Dynamic Consistency Boundary in Chronicle](https://github.com/Cratis/Chronicle/blob/main/Documentation/dynamic-consistency-boundary/chronicle.md): how definitions, decision reads and concurrency scopes relate to DCB. * [Where the clients differ](https://github.com/Cratis/Chronicle/blob/main/Documentation/clients/where-the-clients-differ.md) # Explicit artifact registration > Configure marker-free .NET artifacts, their identities, and their store-local registration lifetime. Use `ChronicleOptions.ExplicitArtifacts` when your contract types cannot reference Chronicle or you want to nominate artifacts at the composition root. Existing attributes and interfaces keep working. Plain reactors and classic instance reducers still require nomination. A concrete, non-generic model with the exact public static self-reduction shape is the exception: it is discovered structurally. See [self-reducing read models](/chronicle/reducers/self-reducing-read-models/). For read models, start with model-bound attributes, then fluent mappings when the definition needs structure, then reducer code when transitions read better; a self-reducing model is the default for new reducers. Use `IReducerFor` for fold-time DI or separation, and nominate a plain reducer only when its assembly cannot reference Chronicle. [Choose how to build a read model](https://github.com/Cratis/Chronicle/blob/main/Documentation/projections/choosing-a-read-model-style.mdx) compares the forms; this page covers their explicit registration. ## Declare artifacts before creating stores The generic and `Type` forms declare the same artifacts: | Generic form | Runtime-type form | Optional configuration | | --- | --- | --- | | `RegisterEventType(configure)` | `RegisterEventType(type, configure)` | Event identity, generation, visibility, owning store, publication sequence | | `RegisterReactor(id)` | `RegisterReactor(type, id)` | `ReactorId` | | `RegisterReducer(id)` | `RegisterReducer(type, id)` | `ReducerId` | | `RegisterConstraint()` | `RegisterConstraint(type)` | None | | `RegisterEventSource(name)` | `RegisterEventSource(type, name)` | Source name | | `RegisterMigration()` | `RegisterMigration(type)` | None | Every method returns the same `ExplicitArtifacts` object. The configuration arguments are optional. Existing projection, model-bound read-model and delegate-reactor registration remain available on that object; see [explicit read-model registration](/chronicle/projections/explicit-registration/). The [composition example](/chronicle/configuration/#explicit-artifacts) keeps event records plain while registering their observers and constraints. ### Defaults and peer declarations Omitted arguments inherit marker declarations, then the existing identity defaults: * Event id: the CLR type’s name; generation: `1`. * Reactor and reducer id: the full CLR type name, with the existing namespace/name fallback. * Event source name: the CLR type name with an `EventSource` suffix removed. * Constraint names: the existing constraint-builder or attribute naming rules. Discovery and explicit registration are peers, not an override stack. Equal declarations for the same CLR type coalesce. Incompatible supplied ids, generations, owning stores, visibility or publication destinations throw `ConflictingArtifactDeclaration`, including declaration provenance. A marked id cannot be silently replaced by an explicit alias. Two CLR types with the same `(id, generation)` are rejected; distinct generations of one id are valid. A type explicitly nominated as both reactor and reducer, or against a contradictory observer marker, throws `ConflictingArtifactKind`. Invalid nominated shapes fail preparation rather than being silently omitted. Local identity, schema or migration failures prevent registration effects; an independently invalid projection retains the existing partial-projection outcome behavior. ### Event facets | Builder method | Meaning | | --- | --- | | `WithId(EventTypeId)` | Stable event identifier | | `Generation(EventTypeGeneration)` | Numeric generation | | `Public()` | Public registration visibility and publication eligibility | | `Private()` | Private registration visibility, without subtracting publication eligibility | | `ForEventStore(EventStoreName)` | Owning store | | `PublishTo(EventSequenceId)` | Destination sequence for event-target publication | Registration visibility and publication eligibility are separate. Ownership in the current store, `[Public]`, or explicit `Public()` can grant eligibility; those contributions form a union. `Private()` never removes ownership-derived eligibility. It conflicts with a supplied public visibility declaration, but not with an ownership-derived default. Declared store ownership, including a foreign store, defaults registration visibility to public. Foreign ownership alone does not grant publication eligibility. Absent visibility and sequence attributes supply no competing declaration. You can therefore add `Public()` or `PublishTo(customSequence)` to an otherwise marked event without conflicting with default private/outbox values. `PublishTo` alone does not make an event eligible for publication, and the event log is not a valid publication destination. The default destination is the outbox. Numeric `Generation(n)` does not mean “historical type.” Exact-dispatch exclusion remains the behavior of `[EventTypeGenerationFor]`; its referenced identity resolves through the completed peer declarations. ## Snapshot and discovery lifetime Each store captures all explicit recipes together when it is constructed. Later options changes affect only subsequently created stores. Reconnect reuses that snapshot. Explicit `DiscoverAll()` can refresh the discovered provider while retaining the original recipes. `AutoDiscoverAndRegister = false` stays manual: call `DiscoverAll()` to apply options recipes, then `RegisterAll()` when connected. An untouched manual store does not silently acquire those recipes. This differs from a fixture’s `AutoDiscoverArtifacts = false`, which disables assembly discovery but does not disable its client’s registration process. An empty explicit snapshot leaves the supplied provider untouched. With nominations, the store merges a local catalog without mutating the provider. Attribute-derived Unique, property-Unique, RemoveConstraint and ClosesStream entries are added only for explicitly nominated events and unioned without duplicates. For discovered-only events, a custom provider’s own derived lists remain authoritative, including intentional omissions. ## Plain observers and constraint definers A nominated reactor uses the existing instance-method dispatch, dependency injection, filters, tags, sequence selection and replay behavior. A nominated classic reducer infers exactly one read model from supported instance `(event, current[, EventContext])` methods. Declare current state nullable; nullable-oblivious declarations retain compatibility. Supported returns remain the read model, `Task` and classic `Task`; classic mutation-only `Task` keeps its existing null-return semantics. The same `RegisterReducer(id)` and `RegisterReducer(typeof(T), id)` APIs also accept a self-reducing model. Its public static `Reduce(event, model? current[, EventContext])` returns the model or `Task`, including nullable results. No model is activated and no new nomination API is needed. Explicit near misses fail with `InvalidSelfReducerSignature`. Implementing `IReducer` always keeps a type classic, even if it has a self-shaped static helper. A self model with another builder in the same store catalog fails with `AmbiguousReadModelBuilder` before registration effects. A plain constraint definer exposes exactly one public synchronous instance `void Define(IConstraintBuilder)` method. Existing `IConstraint` implementations keep their direct interface path. A nominated event source keeps its declared streams and metadata; an explicit name changes the registered identity, not its declaration-only description helpers. The existing constrained generic observer APIs remain. For plain classes, import `Cratis.Chronicle.Reactors` or `Cratis.Chronicle.Reducers` to use the registered-artifact extension siblings, or pass `typeof(YourObserver)` to the Type-based operations. These operations resolve aliases through the store. Plain `Register(Type)` is explicit intent and need not be prelisted in options; its event types must already be registered. Custom observer registries that implement only the old generic interface surface remain compatible. Unsupported new Type operations throw `TypeBasedArtifactRegistrationNotSupported` rather than pretending registration succeeded. ## Typed and plain migrations [Explicit migration registration](/chronicle/migrations/explicit-registration/) uses the existing migration builders and kernel-side generation engine. For unmarked or aliased endpoints, a typed migration calls the additive protected constructor `EventTypeMigration(IEventTypes)`. The client supplies the owning store’s finalized registry, even if ordinary DI contains another registry. Both endpoints must be registered, share one id and have adjacent generations. The parameterless base constructor keeps declaration-only validation. An attributed previous endpoint intentionally omitted by a custom provider remains valid on that legacy path; its placeholder schema behavior is unchanged. Merely listing a legacy migration explicitly does not switch its constructor semantics. A nominated plain, concrete, closed class declares these roles: * `public void Upcast(IEventMigrationBuilder builder)` * `public void Downcast(IEventMigrationBuilder builder)` * Optional `public void MapValues(IEventValueMapBuilder builder)` Each role is one non-generic synchronous instance method with one ordinary parameter. Overloads, ambiguous endpoints, static methods, async void and malformed roles are rejected. Both endpoints must be registered. The adapter uses the typed migration engine, including bidirectional value-map inversion; there is no separate migration language. Constructor dependencies use ordinary DI, with an owning registry supplied explicitly when requested. Plain definers must not implement disposal interfaces because the migration provider has no disposal ownership contract. Every explicit recipe is validated during preparation, even if no discovered upgrade could otherwise select it. Migration nomination does not register missing events automatically. Custom migration providers retain compatible legacy output; opaque results that require a new registry binding but cannot identify their original recipe fail with `ExplicitMigrationBindingNotSupported`. Plain adapter activation makes no new trimming or Native AOT guarantee. The migration-id analyzer does not prove plain-role endpoints; runtime validation does. ## Registry-aware schemas and concurrency Compensation metadata can refer to explicitly registered targets. Built-in schema generation resolves new aliases or unmarked compensation targets through the owning registry without placing store-specific ids in CLR-only caches. Those new targets must be registered. A custom `IJsonSchemaGenerator` retains its original output for declaration-only compensation, even when the marked target is absent from the registry. For new explicit bindings it must implement the additive `Generate(Type, IEventTypes)` and `GenerateLegacyEventType(Type, IEventTypes)` overloads. Otherwise `RegistryAwareEventTypeSchemasNotSupported` fails narrowly on that requirement; no metadata is silently rewritten. For [concurrency scopes](/chronicle/configuration/#registry-resolved-concurrency), pass `store.EventTypes.GetEventTypeFor(typeof(YourEvent))` to existing `WithEventType(EventType)` or `WithEventTypes` methods. Declaration-only Type/generic helpers cannot know an explicit alias. For a source alias, resolve `store.EventSources.GetFor(typeof(YourSource))` and its `FindStream(...)` definition, then use their effective `EventSourceType` and `EventStreamType` with the existing scope builders. ## Testing and analyzer boundaries Per-run `Defaults(artifacts, serviceProvider, options)` honors the same registrations in `ReadModelScenario`, `ReactorScenario` and `EventScenario`. A plain reactor scenario requires explicit nomination; it is not shape-discovered. Give each independent run its own defaults/options rather than changing `Defaults.Instance`. XUnit integration options have the lifetime of the fixture type’s host/client/store, not an individual test run. Configure explicit recipes before that host is created. Reuse keeps its first captured snapshot; a separately created fixture host has a separate snapshot. The sharing boundary is the existing closed `ChronicleClientFixture` family, so derived scenarios sharing that family also share its host. A fixture that overrides `ChronicleFixture.UsesPerContextMongoDBHost` to return `true` starts a fresh host for each test context, so each context captures its own snapshot. Disabling fixture assembly discovery does not remove explicit recipes. See [testing configuration](/chronicle/testing/#explicit-artifacts-and-lifetime). [Analyzer evidence](/chronicle/code-analysis/#explicit-registration-evidence) recognizes possible registrations in the same compilation, not execution or per-client membership. Runtime validation is final authority. # Registering External Services from code > Register External Services programmatically from the .NET client, as an alternative to configuring them in the Workbench. [External Services](/chronicle/external-services/) are usually configured in the Workbench, but the .NET client also exposes a programmatic API on `IEventStore`: ```csharp // An HTTP service with bearer token authentication and a header await eventStore.ExternalServices.Register("CustomersApi", _ => _ .Http("https://api.example.com") .WithBearerToken(token) .WithHeader("X-Tenant", "acme")); // A PostgreSQL database service await eventStore.ExternalServices.Register("CustomersDb", _ => _ .PostgreSql("db.example.com", "customers", "postgres", password, port: 5432)); ``` The builder also exposes `WithBasicAuth`, `WithOAuth`, `MsSql`, and `WithOption`. For scoped client credentials, use `WithOAuth(authority, clientId, clientSecret, scope)`. The existing three-argument overload requests no scope. For Microsoft Entra, the scope is typically `api:///.default`. OAuth tokens are shared by the kernel and applied on every HTTP request, so long-lived clients refresh expired tokens without needing to be recreated. A `401` triggers one token refresh and retry. # Get started with the .NET client > Connect a .NET application to Chronicle and append an event. Use the .NET client when you want direct typed access to Chronicle from a C# application. The smallest useful loop is: 1. create a `ChronicleClient` 2. open an event store 3. append a fact to an event log 4. let projections, reducers, and reactors observe that fact ## Connect For local development, start the Chronicle kernel and connect with the development connection string. ```csharp using Cratis.Chronicle; using var client = new ChronicleClient(ChronicleConnectionString.Development); var eventStore = await client.GetEventStore("Quickstart"); ``` `ChronicleConnectionString.Development` points at the default local Chronicle kernel with development credentials. `ChronicleConnectionString.Default` also points at `localhost:35000`; it names no credentials, so connecting with it falls back to the same development credentials (`auth=none` is the anonymous option). To connect to any other environment, pass an explicit `new ChronicleConnectionString("chronicle://...")` carrying that environment’s host and credentials, and set `skipTlsValidation=false` when its certificate is verifiable — see [Client TLS](/chronicle/configuration/tls/). ## Define an event Events are immutable facts. In .NET, mark event records with `[EventType]` so Chronicle can discover and register their schema. ```csharp using Cratis.Chronicle.Events; [EventType] public record BookAdded(string Title, string Isbn); ``` ## Append the event Append the event against the event source it belongs to. The event source id is not part of the event payload; Chronicle stores it in the event context. ```csharp var bookId = "book-123"; var result = await eventStore.EventLog.Append( bookId, new BookAdded("The Pragmatic Programmer", "978-0135957059")); if (!result.IsSuccess) { // Decide whether to retry or surface the append failure to the caller. } ``` After the append succeeds, Chronicle persists the event and forwards it to the projections, reducers, reactors, and subscriptions that observe the event sequence. ## Use a host integration For ASP.NET Core and Worker Service applications, prefer the host integration instead of creating the client manually. `AddCratisChronicle` extends the application builder (`WebApplicationBuilder` or `IHostApplicationBuilder`), not `builder.Services`: ```csharp builder.AddCratisChronicle(options => options.EventStore = "Quickstart"); ``` The host integration registers the event store, event log, read models, projections, reducers, reactors, constraints, and related services in dependency injection. ## Pin reactor and reducer generations When independently deployed consumers use different generations of an event type, opt in to kernel selection and release of the generation each reactor or reducer handles. Configure this before connecting: ```csharp using Cratis.Chronicle; using Cratis.Chronicle.Observation; var options = new ChronicleOptions(ChronicleConnectionString.Development) { EventGenerationDelivery = EventGenerationDelivery.Pinned }; using var client = new ChronicleClient(options); var eventStore = await client.GetEventStore("Quickstart"); ``` `Compatibility` remains the default. `Pinned` applies to client reactors and reducers on live delivery, catch-up, replay, and partition retry; it does not change projections or event reads. Upgrade all kernel silos before enabling it. Each observer must handle only one generation per event type, and that generation must be registered. The delivered `EventContext.AppendedGeneration` is separate from the handler’s generation and is null for legacy events whose original generation is unknown. See [pinned observer delivery](https://github.com/Cratis/Chronicle/blob/main/Documentation/concepts/event-type-migrations.mdx#opt-in-to-pinned-observer-delivery) for on-demand migration, revision, and hash behavior. ## Next * [Console quickstart](/chronicle/get-started/console/) * [ASP.NET Core hosting](/chronicle/get-started/aspnetcore/) * [Worker Service hosting](/chronicle/get-started/worker/) * [Shared append semantics](/chronicle/events/appending/) # Health checks > Report whether the client is connected and its artifact registration succeeded through the ASP.NET Core health check pipeline. An ASP.NET Core host that cannot reach the Chronicle kernel starts, stays alive, and answers requests — while being unable to serve any of them. The Chronicle health check makes that state visible to whatever is watching your host: a Kubernetes readiness probe, a load balancer, or a dashboard. ## What you get `AddCratisChronicle` registers the health check for you. There is nothing to add. The check is registered under the name **`chronicle`**, with the tags **`chronicle`** and **`ready`**. It checks the event store configured through `ChronicleAspNetCoreOptions.EventStore`: | State | Result | | --- | --- | | Not connected, or the kernel cannot be reached | Failure status (Unhealthy by default) | | Connected, but artifact registration has not completed | Failure status | | Connected, but `Registration.IsRegistering` is `true`, even after an earlier success | Failure status | | Connected, but registration stopped with an exception or any declared projection artifact failed | Failure status | | Connected, `Registration.IsSuccess` is `true`, and `IsRegistering` is `false` | Healthy | | Connected, `AutoDiscoverAndRegister` is off, and the application has not run `RegisterAll` | Healthy | A registration failure carries the run’s exception on the result, when there was one, and the failed artifact type names in `Data["failedArtifacts"]`. Partial registration is not ready, even though the artifacts that succeeded may already serve requests. Identity drift findings alone do not make successful registration unhealthy. The check reads the connection and registration state; it does not itself dial the kernel on every probe. The client’s own watchdog is what keeps reconnecting in the background. Other event stores your application opens are not checked automatically. Registration success covers projection artifacts, not confirmation that every reactor or reducer is live or that projections have caught up with newly appended events. See [Observing artifact registration](/chronicle/clients/dotnet/registration-outcome/#what-it-covers) for those boundaries. ## Exposing it Registering a check does not expose an endpoint — you still map one. The tags let you separate readiness from liveness, which is the distinction that matters here: ```csharp app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = registration => registration.Tags.Contains("ready") }); app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false }); ``` Chronicle tags itself `ready` and deliberately **not** for liveness. A host that cannot reach the kernel should be taken out of rotation, but restarting it does not bring the kernel back — so a liveness probe that failed on this would restart a healthy host in a loop while the kernel is down. ## Choosing the failure status `AddChronicleHealthCheck` takes a failure status, and you can pass `Degraded` when your host can still do useful work without Chronicle: ```csharp builder.Services.AddChronicleHealthCheck(HealthStatus.Degraded); ``` Call this before `AddCratisChronicle`, or on its own in a host that wires the client up itself. Registration is idempotent, so the automatic registration will not add a second check. ## Startup behavior The host starts even when the kernel is unreachable. `UseCratisChronicle` attempts a connection when the application has started, logs a warning if it cannot get one, and leaves the client’s watchdog to keep retrying. The health check is what reports that state in the meantime — so a readiness probe holds traffic back until the connection comes up and artifact registration succeeds, rather than the host failing to start at all. After a kernel restart, readiness drops while the client is disconnected and stays down while it re-registers after reconnecting. `Registration.IsRegistering` marks that interval even though the outcome retains the previous run’s success or failure. A manual `RegisterAll` also makes readiness fail while it is running, including when automatic registration is off. Background-retry attempts set `IsRegistering` too; between attempts, readiness still fails because the last registration outcome is a failure. `WaitForRegistration` waits for the new run’s outcome rather than returning the previous one. ## Related * [Get started](/chronicle/clients/dotnet/getting-started/) — wiring the client into a host. * [Observing artifact registration](/chronicle/clients/dotnet/registration-outcome/) — the registration outcome used by readiness, its failure details, and what it covers. # Querying behavior patterns > Ask what a user usually does in a given context, from the .NET client, backed by the patterns Chronicle mined from event history. [Behavior patterns](/chronicle/patterns/) are recurring combinations of context that Chronicle mined from an event store’s history. The .NET client exposes them on `IEventStore.Patterns`. ## Asking what somebody usually does right now Most applications have one question: *what does this person normally do at this point in the week?* Every Chronicle client offers that as a single call — see [asking about a moment](/chronicle/patterns/) for the shared contract. In .NET it is `GetPatternsAt`, and it takes the scope and nothing else: ```csharp foreach (var pattern in await eventStore.Patterns.GetPatternsAt(userId)) { var command = pattern.Facets.ValueOf(FacetName.CommandType); Console.WriteLine($"{command} — {pattern.Confidence.Value:P0} of the time"); } ``` Each answer names a **command**, and its `Confidence` is the chance of that command given the context it was established in. You get at most one answer per command, so a habit mined at several context sizes at once comes back once rather than three times. The day and the part of the day are read off the moment for you, using the same rule the engine bucketed events with when it mined them — so the answer is about the slot the behavior was actually learned in. Pass a moment to ask about a different one: ```csharp var patterns = await eventStore.Patterns.GetPatternsAt(userId, tomorrowMorning); ``` Add further facets when the question is narrower than a moment — *what does this person usually do with an invoice on a Monday morning?* ```csharp var patterns = await eventStore.Patterns.GetPatternsAt( userId, alsoConstraining: FacetSet.Empty.With(FacetName.AggregateType, "Invoice")); ``` Note Derive the time bucket yourself and you own a copy of a rule the engine also owns. When they drift, nothing fails — the query simply asks about a slot the mining never used and returns nothing. `GetPatternsAt` exists so that copy does not have to exist. If you do need the bucket for something else, `ToTimeBucket()` on `DateTimeOffset` is the same rule the engine mines with. ## Asking about a context that is not a moment `GetUsualActions` is the call underneath. Build a context from the facets you know and ask within a scope — normally the user whose behavior you are asking about: ```csharp using Cratis.Chronicle.Concepts.Patterns; var patterns = await eventStore.Patterns.GetUsualActions( groupingKey: userId, context: FacetSet.Empty .With(FacetName.Day, DayOfWeek.Monday.ToString()) .With(FacetName.AggregateType, "Invoice")); foreach (var pattern in patterns) { Console.WriteLine($"{pattern.Facets.ValueOf(FacetName.CommandType)} — {pattern.Confidence.Value:P0} confident, seen {pattern.Occurrences.Value} times"); } ``` The context may constrain **any subset** of the facets. Facets the store does not mine are discarded rather than narrowing the lookup to nothing, so a caller can describe its situation in whatever terms it has. Results are ranked **most likely first**. Constraining `CommandType` here does nothing — it names the answer rather than the situation, and the answer is what you are asking for. ## Checking whether an action is normal `GetPatterns` asks the opposite question: given a situation you can describe *completely*, including the command, which established patterns cover it? That is the check you make before flagging something as unusual, rather than the prediction above. ```csharp var covering = await eventStore.Patterns.GetPatterns( groupingKey: userId, context: FacetSet.Empty .With(FacetName.CommandType, "DeleteSupplier") .With(FacetName.Day, DayOfWeek.Sunday.ToString()) .With(FacetName.TimeBucket, TimeBucket.Night.ToString())); if (!covering.Any(pattern => pattern.Facets.Constrains(FacetName.CommandType))) { // Nothing established covers this person doing this, here. } ``` A pattern is returned when **its facets are a subset of the context you asked with**, so every result describes something you named. Results are ranked **most specific first**, then most confident: a pattern constraining everything you asked about describes your situation; a broader, more confident one describes a situation you did not ask about. ### An empty result is an answer Both calls return nothing when no pattern clears the confidence bar. That is a true statement — this scope has no established behavior for this context — and it is deliberately not padded with the best of a bad set. Treat “no patterns” as “do not claim to know”: ```csharp var patterns = await eventStore.Patterns.GetUsualActions(userId, context); if (!patterns.Any()) { return "I don't have enough history to say what usually happens here."; } ``` ### Thresholds and limits ```csharp var patterns = await eventStore.Patterns.GetUsualActions( groupingKey: userId, context: context, minimumConfidence: new PatternConfidence(0.8d), maximumResults: 3, cancellationToken: cancellationToken); ``` Leave `minimumConfidence` and `maximumResults` unset to use whatever the **server** is configured for. The client deliberately does not carry its own copy of the thresholds — a default duplicated here would silently disagree with the configured one the moment either changed. ## Browsing everything a scope established `GetPatternsForScope` is the listing call — everything held for a scope, unfiltered, including patterns below the confidence threshold. It is what a browsing view binds to, as opposed to the two calls above, which each answer a question about one situation. ```csharp var everything = await eventStore.Patterns.GetPatternsForScope(userId); ``` Which leaves the question of what to pass. A browsing view rarely knows a scope up front — it has to offer the ones that exist — and patterns are per scope, so there is nothing to show until one is chosen. `GetScopes` is that list: ```csharp foreach (var scope in await eventStore.Patterns.GetScopes()) { var patterns = await eventStore.Patterns.GetPatternsForScope(scope); } ``` The scopes returned are the ones that actually hold patterns, not every identity that ever appeared in the store. A scope missing from the list has established no behavior yet — which is the same answer an empty `GetUsualActions` gives, one level up. ## What you get back Each result is a `BehaviorPattern`: | Member | Type | Meaning | | --- | --- | --- | | `GroupingKey` | `PatternGroupingKey` | The scope the pattern belongs to | | `Facets` | `FacetSet` | The facets the pattern constrains | | `Occurrences` | `PatternOccurrences` | How many times it has been observed | | `Confidence` | `PatternConfidence` | How often it holds when its context is present, 0 to 1 | | `Support` | `PatternSupport` | The share of all observed events it was seen in, 0 to 1 | | `Weight` | `PatternWeight` | Recency-weighted strength — decays as the behavior goes unseen | | `FirstSeen` / `LastSeen` | `DateTimeOffset` | When it was first and last observed | `Occurrences` counts everything that ever happened; `Weight` is how much of that is still recent. Order by `Weight` when recency matters more than history. Read a single facet off a pattern with `ValueOf`, and check whether it constrains one at all with `Constrains`: ```csharp var pattern = patterns.First(); var command = pattern.Facets.ValueOf(FacetName.CommandType); if (pattern.Facets.Constrains(FacetName.TimeBucket)) { // The pattern is specific to a part of the day. } ``` ## Building a context `FacetSet` is canonical and immutable: facets are ordered by name, at most one value per facet survives, and facets with no value are dropped. `With` returns a new set and **replaces** a facet that is already constrained, so a context built up in steps never depends on the order it was written in. ```csharp var context = FacetSet.Empty .With(FacetName.CommandType, "ApproveExpenseReport") .With(FacetName.Day, DayOfWeek.Monday.ToString()) .With(FacetName.Day, DayOfWeek.Tuesday.ToString()); // replaces Monday ``` The facet names are the well-known statics on `FacetName` — `CommandType`, `InitiatorType`, `InitiatorId`, `OnBehalfOf`, `CausedByCommand`, `CorrelationRootId`, `AggregateType`, `Year`, `Month`, `Day` and `TimeBucket`. See [Behavior Patterns](/chronicle/patterns/) for what each one means and which of them are mined by default. ## Making the command visible to mining `CommandType` and `CausedByCommand` are only as good as what named the command. If you append through [Cratis Arc](/arc/), its command pipeline records a `Command` causation naming the executing command and nothing else is needed. Appending directly, add the same property to the causation you scope around the work: ```csharp using Cratis.Chronicle.Auditing; using Cratis.Chronicle.Concepts.Patterns; using (causationManager.BeginScope( "Command", new Dictionary { { WellKnownCausationProperties.CommandType, nameof(ApproveExpenseReport) } })) { await eventStore.EventLog.Append(expenseReportId, new ExpenseReportApproved(approvedBy)); } ``` Use `BeginScope` rather than `Add` for work with a beginning and an end. `Add` is append-only, which is right for a link describing how the work arrived — an HTTP request — but wrong for one describing a bounded piece of work: two such pieces done one after the other both stay on the chain, and the second then reads as caused by the first. That ordering never happened, and pattern mining would learn it as a fact. # Observing artifact registration > Ask an event store whether its declared artifacts registered, instead of re-driving discovery and registration to find out. Chronicle registers your artifacts for you. `RegisterAll` is wired to the connection lifecycle and runs whenever the connection comes up, which is the right default and stays the default. What `IEventStore.Registration` adds is a way to ask afterwards how it went. ## Why you might need it A read model that cannot be built is isolated: it is logged and skipped, so one broken read model no longer costs the rest of your read side. That is a good trade, but it turns a loud failure into a quiet one. The broken read model is simply absent afterwards, and nothing throws. Without an outcome to read, these three situations look identical from outside: * every artifact registered * some registered, and the rest were dropped * registration has not run yet `Registration` tells them apart. ## Reading the outcome ```csharp var outcome = eventStore.Registration; if (!outcome.HasRun) { // Registration has not finished yet - or has not been triggered at all. } if (outcome.Failure is not null) { // Registration ran and did not finish. Whatever registered before it stopped is still in Artifacts. Console.WriteLine($"Registration failed: {outcome.Failure.Message}"); } foreach (var failed in outcome.Failures) { Console.WriteLine($"{failed.ArtifactType.Name} did not register: {failed.Failure!.Message}"); } ``` | Member | What it answers | | --- | --- | | `HasRun` | Whether `RegisterAll` has finished at least once, successfully or not | | `IsRegistering` | A registration run is in flight, or a disconnect is waiting for re-registration on reconnect | | `Artifacts` | Every declared projection artifact, with `IsRegistered` and the `Failure` that stopped it | | `Failure` | What stopped `RegisterAll` itself, or `null` when it finished | | `IsSuccess` | Registration ran, finished, and every declared artifact registered | | `Failures` | Only the artifacts that did not register | `RegistrationOutcome.NotRun` is the value before registration has finished. After a kernel restart, the client re-registers its artifacts when it reconnects. `IsRegistering` becomes `true` on disconnect when automatic registration is enabled, and stays true until re-registration finishes. Manual `RegisterAll` calls and background-retry attempts also set it while running. Between background attempts, the published failure has `IsRegistering` set to false. The other members retain the previous run’s outcome, so `IsSuccess` can still be true while `IsRegistering` is true; check both before treating registration as ready. There are two kinds of failure here and they are not the same. An artifact in `Failures` could not be built, so it was isolated and skipped and registration carried on without it — the rest of the read side is up. A `Failure` on the outcome itself ended the run, so registration stopped wherever it got to. A run that ends this way still reports itself rather than staying at `NotRun`, so waiting on it returns instead of timing out. ## Waiting for it Registration runs on the connection lifecycle, so a consumer that has just constructed a client may simply be early. `WaitForRegistration` waits for it to have run, with the same timeout shape as the other Chronicle wait helpers: ```csharp using Cratis.Chronicle.Registrations; var outcome = await eventStore.WaitForRegistration(TimeSpan.FromSeconds(10)); if (!outcome.IsSuccess) { throw new InvalidOperationException("The read side did not come up."); } ``` It waits for registration to have *run*, not to have succeeded — ask the returned outcome about that. During re-registration, it waits for the new outcome instead of returning the previous run’s result. A run that failed returns here too, carrying its `Failure`; the timeout is for a registration that never finished, not for one that finished badly. If it has not finished within the timeout, the wait throws `TaskCanceledException`. ## Discovery happens before registration Before Chronicle can register artifacts, `DefaultClientArtifactsProvider` discovers their types through `ICanProvideAssembliesForDiscovery`. The first access to any artifact collection initializes **all** collections. Concurrent readers wait for that discovery to finish rather than seeing an empty or partially populated collection. Later reads reuse the completed collections; they do not scan assemblies again. If assembly initialization or type discovery throws, that access fails with the original exception. The provider does not publish the incomplete collections, and the next access retries initialization. This is a retry on access, not an automatic background retry. If you supply custom assembly discovery, finish that work before reading the provider’s artifact collections. Reading them again on the initializing thread throws `ReentrantClientArtifactsInitialization`: discovery cannot consume its own unfinished result. Avoid waiting inside discovery for another thread that reads artifacts too, because that reader waits for discovery to finish. A complete discovery result only says which types exist. It does **not** mean the kernel has registered them; use the registration outcome and observer-state helpers described here for that distinction. ## What it covers `Registration` reports projection artifacts: the fluent `IProjectionFor` implementations and the model-bound read models. Those are the artifacts whose registration round-trips to the kernel inside `RegisterAll`, so the outcome is observed rather than assumed. Reactors and reducers are deliberately not reported. Their registration only opens a duplex stream and returns before the kernel has answered, so the client has nothing to report about them that would be a fact. To know a reactor or reducer is live, wait on its observer state instead — `WaitTillSubscribed` or `WaitTillActive` — which the kernel answers. The outcome is read-only. Whether a failed artifact should abort start-up, fail a spec, or be tolerated is yours to decide. The ASP.NET Core [readiness health check](/chronicle/clients/dotnet/health-checks/) requires a live connection, `Registration.IsSuccess`, and `IsRegistering` to be false; it reports a failure status for in-progress, incomplete, or failed registration. Caution Do not use `IConnectionLifecycle.IsConnected` as a stand-in. It is set to `true` *before* the connected handlers run — registration is one of them — and only rolled back afterwards if one of them failed. Polling it races: it reads `true` while registration is still in flight, and while a registration about to be reported as failed is still running. It answers “connected”, which is necessary and not sufficient. # Chronicle Elixir client > The cratis_chronicle Hex package, its requirements and known limitations, and where to find Elixir setup and API details. `cratis_chronicle` is the Elixir client for Chronicle. You declare events, read models, reactors, reducers and seeders as modules with `use` macros, start `Chronicle.Client` in your supervision tree, and append and query with plain functions that return `:ok`, `{:ok, value}` or `{:error, reason}`. Shared Chronicle concepts and workflows live in the main Chronicle docs and show an Elixir tab where the code differs by client. This section covers what is specific to Elixir: Mix setup, supervision, connections, process-scoped context, and Elixir-only APIs. **New here?** Start with [Get started](/chronicle/clients/elixir/get-started/). It takes you from `mix new` to a projected read model in a few minutes. ## Requirements and compatibility | Requirement | Details | | --- | --- | | Package | [`cratis_chronicle`](https://hex.pm/packages/cratis_chronicle) on Hex. | | Elixir | 1.18 or later. CI uses Elixir 1.19.5 on Erlang/OTP 28.5. | | Dependencies | `grpc ~> 1.0`, Mint 1.11 or later, `opentelemetry_api ~> 1.4`, and `cratis_chronicle_contracts ~> 19.56`. | | Kernel | A kernel whose gRPC contracts match the `cratis_chronicle_contracts` version in your `mix.lock`. A mismatch returns `{:error, {:incompatible_server, ...}}`; see [Get started](/chronicle/clients/elixir/get-started/#troubleshooting). | | API reference | [HexDocs](https://hexdocs.pm/cratis_chronicle) | ## Known limitations See [Limits and gaps](/chronicle/clients/elixir/limits-and-gaps/) for the kernel limits, the consistency guarantees and what the Elixir client does not do yet. * The client doesn’t validate the kernel’s TLS certificate unless you set `skipTlsValidation=false`. See [Connection strings](/chronicle/clients/elixir/connections/connection-strings/#tls). * Version 3.5.0 had defects in read model mappings, reducer registration, constraint registration, seeding, read model paging, and sequence number lookups. Upgrade to 3.5.1 or later. ## Shared Chronicle topics * [Get started](/chronicle/get-started/) * [Events and event logs](/chronicle/events/) * [Appending events](/chronicle/events/appending/) * [Read models](/chronicle/read-models/) * [Projections](/chronicle/projections/) * [Reactors](/chronicle/reactors/) * [Reducers](/chronicle/reducers/) * [Constraints](/chronicle/constraints/) * [Event seeding](/chronicle/event-seeding/) * [Compliance](/chronicle/compliance/) * [Transactions and unit of work](/chronicle/events/transactions/) * [Event evolution](/chronicle/understanding-event-evolution/) ## Elixir-specific pages * [Get started](/chronicle/clients/elixir/get-started/): install the client, connect to a development kernel, append an event and read a read model * [Connections](/chronicle/clients/elixir/connections/): connection strings, credentials, TLS, and how the client recovers from dropped connections * [Context management](/chronicle/clients/elixir/context/): process-scoped correlation ids, identities, and causation chains * [Concepts](/chronicle/clients/elixir/concepts/): strongly typed values and PII classification with `Chronicle.Concept` * [Seeding](/chronicle/clients/elixir/seeding/): seeders and when seed data is appended * [Sinks](/chronicle/clients/elixir/sinks/): choosing where read models are stored, and running several clients * [Event store discovery](/chronicle/clients/elixir/event-stores/): listing event stores and namespaces * [Event store subscriptions](/chronicle/clients/elixir/event-store-subscriptions/): importing events between event stores * [Jobs](/chronicle/clients/elixir/jobs/): inspecting and controlling Chronicle jobs * [Webhooks](/chronicle/clients/elixir/webhooks/): registering webhooks from Elixir * [External services](/chronicle/clients/elixir/external-services/): registering HTTP and database external services * [Failed partitions](/chronicle/clients/elixir/failed-partitions/): inspecting observers whose partitions have failed * [Observers](/chronicle/clients/elixir/observers/): listing observers and removing one whose declaring code is gone * [Captures](/chronicle/clients/elixir/captures/): declaring captures that turn external input into events * [Dynamic Consistency Boundary](/chronicle/clients/elixir/dynamic-consistency-boundary/): decision reads that guard a unit of work against concurrent appends * [Behavior patterns](/chronicle/clients/elixir/patterns/): asking what a scope usually does at a given moment * [Discovery and identity drift](/chronicle/clients/elixir/discovery/): the discovery report, identity drift policies and `mix chronicle.discover` * [Observability](/chronicle/clients/elixir/observability/): OpenTelemetry spans, `:telemetry` events and trace propagation * [Testing](/chronicle/clients/elixir/testing/): the in-memory kernel, assertions and isolated integration helpers # Captures > Define Chronicle captures with the Elixir DSL, register them, and manage their lifecycle. `Chronicle.Captures.Capture` provides a DSL for defining Chronicle captures, and `Chronicle.Captures` provides an idiomatic Elixir API for registering, starting, stopping, deleting, listing, and validating them. Captures turn data that arrives from outside the event log — an external API, an inbound webhook, or another event store’s public events — into events appended to this event store. The DSL compiles at build time to Capture Declaration Language (CDL) text, the same text the kernel’s Workbench and `SaveCapture` accept. See the Chronicle documentation’s Capture Declaration Language reference for the full grammar. The capturing engine runs a subset of the language As of Chronicle 19.6, the kernel’s capturing engine reads only `api` and `events` sources, and runs only root-level `append` rules — `map`, `nested`, and `children` compile and validate, but are not applied at runtime. ## Defining a capture ```elixir defmodule MyApp.Captures.InvoiceCapture do use Chronicle.Captures.Capture, name: "InvoiceCapture" api_source("InvoicingApi", route: "/invoices", poll: "10m") key("id") append MyApp.Events.InvoiceStatusChanged, when: "status" do set(:status, "$.status") end end ``` * `use Chronicle.Captures.Capture` registers `:name` (defaults to the module’s last segment) and `:id` (defaults to a deterministic UUIDv5 derived from the module’s full name, so re-registering the same module updates the same kernel-side capture rather than creating a new one every deploy). Pass `start: true` to have `Chronicle.Captures.Registrar` start the capture once it has been saved. * Exactly one source is required: `api_source/1,2` (polls a named [External Service](/chronicle/clients/elixir/external-services/)), `webhook_source/1` (an inbound webhook path), or `events_source/1` (captures from another event store’s public events, given as a list of registered `Chronicle.Events.EventType` modules). * `key/1` names the property path used to identify and diff a captured item. For an events source, use `"$eventSourceId"`. * `append/2,3` declares an event to append when its `when:` condition matches; `set/2` inside its block maps a target field of the event’s struct from a source expression (`"$.path"` for the current payload, `"$context."` for the capture or event context). An append’s event type must start with an uppercase letter The kernel’s CDL parser lexes the target of `append ` as a type-name token that must start with an uppercase letter and contain only letters, digits, and underscores — no hyphens — even though Chronicle otherwise lets you register an event type with any id. Register the event type you intend to append from a capture with a matching id: ```elixir # Works as an append target - starts with an uppercase letter. use Chronicle.Events.EventType, id: "InvoiceStatusChanged" # Compiles, but can never be targeted by append/2,3 (fails at compile time with a clear error). use Chronicle.Events.EventType, id: "invoice-status-changed" ``` `append/2,3` raises `ArgumentError` at compile time when the resolved id does not match this shape, rather than letting the capture fail validation on the kernel. `events_source/1`’s `from` lines have no such restriction — the kernel’s grammar treats them as free text. ## `when:` conditions ```elixir append MyApp.Events.CustomerChanged, when: "firstName" do set(:first_name, "$.firstName") end append MyApp.Events.CustomerChanged, when: {:any, ["firstName", "lastName"]} do set(:first_name, "$.firstName") end append MyApp.Events.CustomerActivated, when: {:transition, "status", "inactive", "active"} do set(:status, "$.status") end append MyApp.Events.ItemAdded, when: :added do set(:line_number, "$.lineNumber") end ``` * a string — fires when that property changes * `{:any, [...]}` / `{:all, [...]}` — an “or”/“and” of several properties * `{:transition, property, from, to}` — fires when a property moves between two specific values * `:added` / `:removed` — fires when the item is first seen or disappears * `{:expression, "template"}` — a raw CDL expression ## Map operations ```elixir map do rename(:full_name, :fornavn) template(:greeting, "Hello, ${fornavn}") translate(:status, :status, [{"aktiv", "active"}, {"inaktiv", "inactive"}]) split(:full_name, by: ",", into: [:first_name, :last_name]) end ``` ## Nested and children scopes ```elixir nested :billing_address do map do rename(:street, :"fakturaAdresse.gate") end append MyApp.Events.BillingAddressChanged, when: "street" do set(:street, "$.street") end end children :line_items, identified_by: :line_number do append MyApp.Events.ItemAdded, when: :added do set(:line_number, "$.lineNumber") end end ``` ## Registering a capture Register a capture module explicitly with `Chronicle.Client`: ```elixir {Chronicle.Client, connection_string: "chronicle://localhost:35000", event_store: "my-store", captures: [MyApp.Captures.InvoiceCapture]} ``` Or rely on `otp_app:` auto-discovery — modules that export `__chronicle_capture__/1` are discovered automatically, the same way reactors, reducers, and webhooks are. Either way, `Chronicle.Captures.Registrar` saves every discoverable capture once the client’s connection reaches the `:registered` phase, and re-registers on every reconnect: * It is idempotent: it compares the compiled declaration against what `Chronicle.Captures.list/1` reports and skips a capture whose declaration already matches. * It warns, rather than fails, before saving over a capture that is `:started` and whose declaration changed — saving a started capture stops it on the kernel, so it comes back stopped. * It starts a capture only when it declares `start: true`. ## Managing a capture directly ```elixir {:ok, capture} = Chronicle.Captures.register(MyApp.Captures.InvoiceCapture) :ok = Chronicle.Captures.start(MyApp.Captures.InvoiceCapture) {:ok, captures} = Chronicle.Captures.list() {:ok, messages} = Chronicle.Captures.validate(MyApp.Captures.InvoiceCapture) {:ok, events} = Chronicle.Captures.captured_events("InvoiceCapture") :ok = Chronicle.Captures.stop(MyApp.Captures.InvoiceCapture) :ok = Chronicle.Captures.delete(MyApp.Captures.InvoiceCapture) ``` Every function also accepts a raw capture id (a GUID string) instead of a module for `start/2`, `stop/2`, and `delete/2`, and raw CDL text instead of a module for `validate/2` — useful for validating a declaration before it is ever saved. ## Introspection ```elixir MyApp.Captures.InvoiceCapture.__chronicle_capture__(:id) MyApp.Captures.InvoiceCapture.__chronicle_capture__(:name) MyApp.Captures.InvoiceCapture.__chronicle_capture__(:declaration) MyApp.Captures.InvoiceCapture.__chronicle_capture__(:start?) ``` # Compliance > Compliance and PII handling are shared Chronicle topics. The shared docs own the concepts, storage behavior, and client-tabbed examples. Compliance and PII handling are shared Chronicle topics. The shared docs own the concepts, storage behavior, and client-tabbed examples. * [Compliance](/chronicle/compliance/) * [PII](/chronicle/compliance/pii/) * [Compliance in read models](/chronicle/compliance/read-models/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Erasure and re-enrollment `Chronicle.Compliance.delete_encryption_key/2` is the right-to-erasure operation for a PII subject. Deleting a key does not by itself permit a replacement: until the kernel is told a new key is allowed, new PII values for that subject are rejected. When the subject has legitimately returned, authorize a new key with `Chronicle.Compliance.allow_new_encryption_key_for/2`: ```elixir :ok = Chronicle.Compliance.delete_encryption_key("customer-42") # later, the person registers again :ok = Chronicle.Compliance.allow_new_encryption_key_for("customer-42") ``` A kernel that does not support the operation returns `{:error, :not_supported}`. # Concepts > Give Elixir domain values their own types with Chronicle.Concept, and classify them as PII once for every event and read model. A `String.t()` is a `String.t()` is a `String.t()`. Nothing stops an employee id being passed where a customer id was expected, or a plain email address sitting in an event field with no signal at all that it needs to be encrypted. Both compile. Both look fine in code review. The bug — or the compliance gap — surfaces later, in production. `Chronicle.Concept` gives a domain value a type of its own: a small wrapper struct that declares what the value *is*, and, when it matters, that it is personally identifiable information Chronicle should protect. ## Declaring one Use `Chronicle.Concept` in a module and declare the primitive type it wraps: ```elixir defmodule MyApp.PersonName do use Chronicle.Concept, type: :string end defmodule MyApp.Age do use Chronicle.Concept, type: :integer end ``` Supported types are `:string`, `:integer`, `:float`, `:boolean`, and `:uuid`. Anything else raises `ArgumentError` at compile time, with the list of valid types in the message. A concept is a plain struct with a single field: ```elixir %MyApp.PersonName{value: "Ada Lovelace"} ``` Use it wherever a raw value used to sit, as the **default value** of a `defstruct` field on an event type or read model: ```elixir defmodule MyApp.Events.EmployeeRegistered do use Chronicle.Events.EventType, id: "employee-registered" defstruct name: %MyApp.PersonName{}, department: "" end ``` The default matters, not just as a starting value: `Chronicle.Schemas.JsonSchemaGenerator` reads struct field defaults to build the JSON schema Chronicle registers, so a field’s default is also how the generator learns the field’s type. A field that should be recognized as a `MyApp.PersonName` needs `%MyApp.PersonName{}` as its default — `nil` carries no type information to infer from. ## What goes on the wire A concept serializes as the value it wraps, not as an object wrapping one: ```elixir iex> Jason.encode!(%MyApp.PersonName{value: "Ada Lovelace"}) "\"Ada Lovelace\"" ``` That is what makes a concept something you can adopt for a property that is already in production. The JSON does not change, the schema Chronicle validates against does not change, and every event stored before the concept existed still reads back. This mirrors the intent of Kotlin’s `ConceptAs` and its `ConceptTypeAdapterFactory`, and C#‘s `ConceptAs`. ## Concepts and compliance (PII) The most useful thing a concept does is carry its compliance classification with it. Declare `pii/0,1` once, on the concept, and every event or read model field that uses it is encrypted automatically — no need to remember `Chronicle.Compliance.pii/1,2` on every event that happens to carry the value: ```elixir defmodule MyApp.PersonName do use Chronicle.Concept, type: :string pii("Legal name of the data subject") end ``` The optional `details` argument documents *why* the value is classified as PII — the legal basis or retention period, for example. It is stored in the generated schema for compliance tooling to read; it plays no part in encryption. `Chronicle.Schemas.JsonSchemaGenerator` resolves PII from two places, and applies both consistently across event type registration and read model registration: * **Property-level** — `pii(:field, details)` declared on the event type or read model module itself, exactly as before. * **Type-level** — a field whose default value is an instance of a `Chronicle.Concept` module that declared `pii/0,1` on itself. The generator also **descends into nested structs**, so a concept does not have to sit directly on an event or read model field — it is still found several levels down, inside a plain value object: ```elixir defmodule MyApp.Address do defstruct street: %MyApp.PersonName{}, postal_code: "" end ``` and inside a **list**, where the first element’s schema (including its compliance metadata) becomes the schema for every item: ```elixir defmodule MyApp.Events.EmployeeRegistered do use Chronicle.Events.EventType, id: "employee-registered-with-aliases" defstruct name: %MyApp.PersonName{}, aliases: [%MyApp.PersonName{}] end ``` Compliance metadata always ends up on a schema **leaf** — never on a container node describing a whole object — with one deliberate exception: compliance declared on the array *field itself* (rather than resolved from its element type) stays on the array, because Chronicle blob-encrypts that collection as a whole. A leaf reached through more than one path (a concept’s own PII plus a redundant property-level `pii/1,2` on the same field) is only ever recorded once. ## Event source ids are never PII A concept can also stand in for a Chronicle event source identifier: ```elixir defmodule MyApp.EmployeeId do use Chronicle.Concept, type: :uuid, event_source_id: true end ``` Chronicle uses the event source id to look up the encryption key for a subject’s PII values, so it cannot itself be one of the encrypted values — you cannot use the key to find the key. Combining `event_source_id: true` with `pii/0,1` on the same concept raises `ArgumentError` at compile time, mirroring the C# client’s `PIINotSupportedOnEventSourceId`: ```elixir defmodule MyApp.EmployeeId do use Chronicle.Concept, type: :uuid, event_source_id: true # Raises ArgumentError at compile time. pii() end ``` If the identifier itself is sensitive, use a non-sensitive surrogate (a random `:uuid` concept with `event_source_id: true` and no `pii`) as the event source id, and store the sensitive value in a separate PII-marked concept. ## Introspection `__chronicle_concept__/1` exposes a concept’s metadata, accepting `:type`, `:event_source_id?`, or `:pii` as the key: ```elixir MyApp.PersonName.__chronicle_concept__(:type) #=> :string MyApp.PersonName.__chronicle_concept__(:event_source_id?) #=> false ``` See [Compliance](/chronicle/clients/elixir/compliance/) for the full PII picture, including right-to-erasure key deletion. # Optimistic concurrency from Elixir > Guard Elixir appends with an expected sequence number and handle a conflict before deciding whether to retry. Two requests can read the same account state and each decide that a withdrawal is allowed. If both append without a concurrency check, neither decision accounts for the other withdrawal. An optimistic concurrency scope lets you append only if the events your decision depends on have not changed. Chronicle checks the expected tail sequence number against the current tail within that scope. If they differ, it rejects the append with a concurrency violation. The check does not lock the event source while your application makes its decision. See [Optimistic concurrency](/chronicle/events/concurrency/) for the shared concept and scope semantics. ## Guard an append Use `Chronicle.Events.ConcurrencyScope.for_event_source/2` to check the event source you are appending to. Pass the resulting scope through the `:concurrency_scope` option to `Chronicle.append/3` or `Chronicle.append_many/3`. This excerpt assumes a [configured client](/chronicle/clients/elixir/get-started/), an existing account, and a registered withdrawal event type. `account_id` identifies the account, `expected_tail` is the tail sequence number observed when loading the state used for the decision, and `withdrawal_event` is the event struct produced by that decision: ```elixir alias Chronicle.Events.ConcurrencyScope scope = ConcurrencyScope.for_event_source(expected_tail) case Chronicle.append(account_id, withdrawal_event, concurrency_scope: scope) do :ok -> :ok {:error, {:concurrency_violations, violations}} -> {:error, {:concurrency_violations, violations}} {:error, reason} -> {:error, reason} end ``` The append succeeds only if the scoped tail still matches `expected_tail`. A competing append outside the scope does not cause a conflict. The example returns conflicts to its caller rather than retrying them. On a conflict, reload the state and reconsider the business decision before constructing another event. Do not just replace the expected sequence number and resend a withdrawal calculated from stale state. Other errors, including connection failures, remain separate from concurrency violations. ## Choose the scope and timing The event source id comes from the append call, not from a string passed to `for_event_source/2`. Its optional keyword arguments can narrow the check by event types, event source type, stream type, or stream id. Keep that narrowing consistent with the events used to make your decision. `ConcurrencyScope.at_tail/2` reads the current tail and returns a scope in one call. Read it before the decision it is meant to protect: fetching a fresh tail after deciding from older state leaves that earlier interval unchecked. For a creation-only operation, use `ConcurrencyScope.expecting_no_matching_event/1` to assert that no event matches the scope yet. ## Client defaults By default, an append without an explicit or event-source-definition-derived scope sends no concurrency constraint. The `:concurrency` option on `Chronicle.Client` can enable automatic tail checks with `default: :optimistic`. An explicit or definition-derived scope takes precedence. This automatic check covers the interval between its tail lookup and the append, not an earlier application read. With that strategy, `check_first_append: true` also guards the first append into an empty scope; the default is `false`. See [Event sequences](/chronicle/clients/elixir/event-sequences/#concurrency) for configuration, scope builders, and the restriction on unequal scopes for the same event source in a batch. # Connections > How the Elixir client holds a long-lived gRPC connection to a Chronicle kernel, and where to configure credentials, TLS and recovery. A Chronicle client keeps one long-lived gRPC connection to a Chronicle kernel. Everything the client does flows over it: appending events, registering projections, running reactors and reducers, seeding. Because it is long-lived, it *will* be interrupted: kernels restart, networks blip, load balancers recycle connections. The client treats those interruptions as normal and recovers from them without losing observers. ## Guides * [Connection strings](/chronicle/clients/elixir/connections/connection-strings/): the `chronicle://` and `chronicle+srv://` formats, client credentials and API keys, TLS validation, and the other options. * [Resilience and the connection lifecycle](/chronicle/clients/elixir/connections/resilience/): how the client connects, registers your artifacts, detects a dead session and reconnects, and what your calls return while it does. * [Keep credentials out of crash reports](/chronicle/clients/elixir/connections/crash-reports/): when gRPC and Mint crash reports can print an API key or bearer token, and the production Logger setup that keeps them out of your logs. ## At a glance Start a client in your supervision tree with a connection string: ```elixir {Chronicle.Client, connection_string: "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000", event_store: "my-app", otp_app: :my_app} ``` That connection string carries the development kernel’s well-known credentials. Use real credentials, and `skipTlsValidation=false`, for any other kernel. The client connects in the background. Until it has a channel, appends and most other calls return `{:error, :not_connected}` rather than waiting, and registration of your artifacts finishes a little later. Wait for readiness before your first call: ```elixir alias Chronicle.Connections.Lifecycle :ok = Lifecycle.wait_until(Lifecycle.name_for(Chronicle.Client), :registered) ``` You never manage the socket, the session, or re-registration yourself. # Connection strings > The chronicle:// and chronicle+srv:// URL formats, authentication modes, TLS options, and Chronicle.Client overrides for the Elixir client. A connection string tells the client which kernel to connect to, how to authenticate and how to secure the channel. It is a URL, so host, port, credentials and options travel in one value you can keep in an environment variable. ```text chronicle://[client-id:client-secret@]host[:port][,host[:port]...][?option=value&...] chronicle+srv://service-name[?option=value&...] ``` Pass it to `Chronicle.Client` as `connection_string:`, either as a string or as a `Chronicle.Connections.ConnectionString` struct. When you pass none, the client uses `chronicle://localhost:35000`, which has no credentials. ## Authentication The client supports two authentication modes, chosen by what the connection string contains. **Client credentials** go in the URL userinfo. The client exchanges them for an OAuth access token at the kernel’s `/connect/token` endpoint, attaches the token to every call, and refreshes it before it expires: ```text chronicle://client-id:client-secret@chronicle.example.com:35000 ``` **An API key** goes in the `apiKey` query parameter and is sent as an `api-key` header on every call. When a connection string has both, the API key wins: ```text chronicle://chronicle.example.com:35000?apiKey=your-api-key ``` With neither, the client connects without authentication. grpc 1.x retains channel and per-call headers in its response-process state, so moving authentication into a per-call interceptor alone does not keep secrets out of an OTP crash report. The client installs a primary Logger filter that removes the API key, the bearer token and the header-bearing gRPC and Mint state from the crash reports of those dependency processes (see [Keep credentials out of crash reports](/chronicle/clients/elixir/connections/crash-reports/)). It does not cover application logging, custom gRPC connections, or other processes that inspect or print credentials. Keep connection strings and request metadata out of logs, and restrict access to production logs. The Elixir client does **not** substitute Chronicle’s development credentials the way the .NET client does. A development kernel that requires authentication accepts the connection but never lets the client finish registering, so spell the development credentials out locally: ```text chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000 ``` `Chronicle.Connections.ConnectionString.development/0` returns the same value as a struct. These credentials are well known and only meaningful to a kernel configured to accept them; never configure a shared or production kernel with them. URL-encode reserved characters in a client id or secret, such as `@`, `:` and `/`. The client decodes both as form-encoded values, so a literal `+` must be written as `%2B`, or it becomes a space. ## Options | Option | Default | Purpose | | --- | --- | --- | | `apiKey` | none | API key sent with every call. Takes precedence over client credentials. | | `skipTlsValidation` | `true` | Whether to skip validating the server’s certificate chain. Set `false` to validate against the operating system’s trust store. Applies to the gRPC channel and the token request. | | `disableTls` | `false` | Connect over plain HTTP/2. The kernel requires TLS on its port, so only use this behind a proxy that terminates TLS for you. | | `loadBalancer` | `least-connections` | How to pick among several hosts or SRV-resolved addresses: `least-connections`, `round-robin` or `random`. | | `srvNameServer` | system resolver | For `chronicle+srv://`, the DNS server to query, as `host` or `host:port`. | | `authPort` | the first host’s port | Port for the `/connect/token` request, when it differs from the gRPC port. | | `certificatePath` | none | Path to a PKCS#12 (`.p12`/`.pfx`) file or a PEM bundle containing a client certificate and private key. Applied to the gRPC channel and OAuth token request. An empty value is treated as absent. | | `certificatePassword` | none | Password for a PKCS#12 file or encrypted PEM private key. An empty value without `certificatePath` is ignored; a non-empty value without a path, or with an empty path, is rejected at startup (unlike the .NET client). | A host without a port uses `35000`. IPv6 addresses use brackets, as in `chronicle://[::1]:35000`. ## TLS The Chronicle kernel always serves TLS. In development it generates a self-signed certificate on every start, which no client can validate. So by default the Elixir client encrypts the connection but **accepts any server certificate without validating it**. That lets you connect to a development kernel with no setup, but on its own it doesn’t protect against a server impersonating your kernel. Turn on certificate validation outside development The client logs a warning at startup when a connection to a remote host skips validation. Give production kernels a certificate your operating system trusts, and add `skipTlsValidation=false` to the connection string: ```text chronicle://client-id:client-secret@chronicle.example.com:35000?skipTlsValidation=false ``` The Elixir client does not use the configured client certificate to trust the server, unlike the .NET client’s fallback. To connect to a kernel with a self-signed certificate while validating the server, provide its certificate as a trusted CA through the gRPC credential’s `:cacerts` option. `skipTlsValidation=true` is for development only. If your kernel requires mutual TLS, set `certificatePath` to a PKCS#12 (`.p12`/`.pfx`) file or a PEM file containing both the client certificate and private key. Include intermediate certificates in the bundle when the server trusts only the root. Supply `certificatePassword` when the file is password-protected. PKCS#12 loading requires the `openssl` executable. If OpenSSL 3 rejects an older PKCS#12 encryption algorithm, the client retries with OpenSSL’s legacy provider (`-legacy`), which must be available. The password is passed through standard input, never a command-line argument, and the extracted key stays in memory. The client presents the certificate to both the gRPC channel and the OAuth token endpoint; the private key stays in a process-owned signing table rather than the channel credential. An unreadable file, invalid certificate, or wrong password fails connection startup; `disableTls=true` cannot be combined with a client certificate. A configured client certificate must use the Mint gRPC adapter. The certificate is matched to its private key, regardless of bundle order; mismatched bundles fail at startup. Conflicting custom TLS identity options and Mint module-level `transport_opts` are rejected at startup because grpc 1.0.5 still merges module options after the credential and would otherwise replace the client identity. The client always sends `enable_push: false` to Mint; application-level `config :grpc, GRPC.Client.Adapters.Mint, client_settings: [...]` must also include `enable_push: false`, or connection startup fails. With a client certificate, put server-verification options on the `GRPC.Credential` passed as `:cred` through `:grpc_options`, not in `:adapter_opts` `:transport_opts`; adapter-level server-verification options are rejected at startup. Server verification uses Mint/OTP defaults or the caller’s credential settings, not the client certificate. OTP 28 supports Ed25519 client identities with TLS 1.3 only; for TLS 1.2, use an RSA or ECDSA identity. A client certificate does not validate the *server*. To trust a private certificate authority, install it in the operating system’s trust store and set `skipTlsValidation=false`, or supply its certificate in `:cacerts` on a gRPC credential with `verify: :verify_peer`. For example, pass `grpc_options: [cred: GRPC.Credential.new(ssl: [verify: :verify_peer, cacerts: [trusted_der]])]`, where `trusted_der` is the DER-encoded trusted certificate. A gRPC credential controls server trust only for the gRPC channel: the client-credentials token request always follows `skipTlsValidation` and the system trust store. When `certificatePath` is configured, the client certificate is added to the gRPC credential even if you pass your own `:cred`. The client requires grpc 1.x, Mint 1.11 or later, contracts 19.19 or later, and Elixir 1.18 or later. It explicitly selects the Mint adapter; grpc 1.x makes Gun optional and no longer requires you to supervise `GRPC.Client.Supervisor`. If your application calls `GRPC.Stub.connect/2` independently, select `adapter: GRPC.Client.Adapters.Mint` there too (or add Gun as a direct dependency). ## Several hosts and DNS SRV List several hosts, comma-separated, and the client picks one on every connect and reconnect using the `loadBalancer` strategy: ```text chronicle://client-id:client-secret@chronicle-1:35000,chronicle-2:35000,chronicle-3:35000 ``` `chronicle+srv://` resolves a single name through a DNS SRV lookup of `_chronicle._tcp.` instead. The lookup runs again on every connection attempt, so membership changes are picked up without a restart: ```text chronicle+srv://chronicle.example.com ``` Client-credential tokens are always requested from the first configured host. Least-connections HTTP probes do not present a client certificate; if your kernel requires mutual TLS on `/connections/count` or `/connections/reserve`, use `loadBalancer=round-robin` or `loadBalancer=random` for multiple hosts. ## Chronicle.Client overrides Some settings can also be passed straight to `Chronicle.Client`, where they win over the connection string: | Client option | Overrides | | --- | --- | | `:skip_tls_validation` | `skipTlsValidation` | | `:load_balancer` | `loadBalancer` (`:least_connections`, `:round_robin` or `:random`) | | `:grpc_options` | Extra options for `GRPC.Stub.connect/2`, such as `:cred` | | `:reconnect_base_delay`, `:reconnect_max_delay` | The reconnect backoff, in milliseconds. See [Resilience](/chronicle/clients/elixir/connections/resilience/). | ## Examples Keep the connection string in configuration rather than in code, for example in `config/runtime.exs`: ```elixir import Config config :my_app, :chronicle_connection_string, System.fetch_env!("CHRONICLE_CONNECTION_STRING") ``` Then read it when you start the client: ```elixir {Chronicle.Client, connection_string: Application.fetch_env!(:my_app, :chronicle_connection_string), event_store: "my-app", otp_app: :my_app} ``` Typical values: ```text # Local development kernel chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000 # Production with client credentials and certificate validation chronicle://my-service:client-secret@chronicle.example.com:35000?skipTlsValidation=false # Production with an API key and certificate validation chronicle://chronicle.example.com:35000?apiKey=your-api-key&skipTlsValidation=false ``` `Chronicle.Connections.ConnectionString.parse/1` raises `ArgumentError` for a malformed string, so a bad value fails when the client starts rather than on the first call. Don’t log a connection string as-is, because it can contain a secret. Crash reports from the gRPC libraries can also print credentials; see [Keep credentials out of crash reports](/chronicle/clients/elixir/connections/crash-reports/). # Keep credentials out of crash reports > When gRPC and Mint crash reports can print your API key or bearer token, and the production Logger setup that keeps them out of shipped logs. The client authenticates every call with an API key or an OAuth bearer token, sent as request headers. The gRPC and Mint libraries underneath it keep those headers in process state. When one of those processes crashes, OTP writes its state, last message and stacktrace arguments to the log at `:error` level, so an unprotected crash report can contain your credentials in clear text. Chronicle’s own processes hide their credentials from crash reports, and the client installs a primary Logger filter that does the same for the dependency processes. You don’t need an application-level filter for the report shapes below. ## What the client redacts `Connection.connect/2` installs a primary Logger filter (`Chronicle.Connections.ResponseCrashRedaction`). A primary filter runs before any handler formats the event, including Elixir’s own OTP report translator, so it covers the console, files and every handler you add. The filter replaces only the parts that carry credentials and keeps the rest of the report readable: | Report | What is replaced | What stays | | --- | --- | --- | | `gen_server`, `gen_statem` and `gen_event` terminate reports | The state, last message and reason where they hold a gRPC stream, `GRPC.Client.Connection`, the Mint connection process state, a Mint HTTP/2 connection or an HPACK table, or an `api-key` or `authorization` header | The process, the callback and function, the reason and the stacktrace | | `proc_lib` crash reports (with `handle_sasl_reports: true`) | The same parts, including message queue contents | Initial call, ancestors, links, status | | Supervisor `child_terminated`, `start_error` and shutdown reports (with `handle_sasl_reports: true`) | The same parts in the reason and in the child’s start arguments | The supervisor, the child id, restart type | | The Mint response process termination report | The whole report, replaced by a one-line notice such as `gRPC Mint response process terminated (RuntimeError); crash report suppressed to protect authentication headers` | The notice names the failure class | | Plain string and format log events at `:warning` or above | The API key, the password and the OAuth tokens this client has used, wherever they occur, and the value of any `api-key`, `authorization` or `Bearer` text | Everything else in the message | A redacted part is printed as a placeholder such as `:"[redacted: GRPC.Client.Adapters.Mint.ConnectionProcess.State holds request headers]"` or as `[redacted]`. Why the filter has to look at several shapes: the `StreamResponseProcess` state holds the whole `%GRPC.Client.Stream{}`, including the channel’s `api-key` header and the per-call `authorization` header. Mint’s HTTP/2 connection keeps the headers it has sent in its HPACK encode table, and `api-key` is not in the static table, so the value is stored there; that is in the `ConnectionProcess` state. `GRPC.Client.Connection` holds the channel and its headers. The client also sends `enable_push: false` to Mint, which removes the route where a server `PUSH_PROMISE` frame crashes `ConnectionProcess`. The client remembers the credential values it uses only in memory, in a list of at most 16 values (the newest win, so a rotating token cannot grow it), so it can scrub them from free-form text. They are never logged or written anywhere else. Values shorter than eight characters are ignored, because scrubbing them would mangle unrelated log text. ## What can still reach a log The filter is a client-side safeguard, not a guarantee: * It is installed at runtime by the first `Connection.connect/2`. Anything logged before that is not covered, and it does not survive a restart of the `:logger` application. * It recognizes the known state shapes and credential header names. A crash report that holds a credential in some other form, for example a secret you pass in your own process state or message, is not recognized. Text scrubbing only knows the values this client has used. * It does not look at `:info` and `:debug` text events, or at messages your own code builds. * Process state can still be read outside logging: `:sys.get_state/1`, the observer, remote shells and crash dumps. If you need more than that, keep `handle_sasl_reports` at its default `false`, restrict who can read production logs, and rotate an API key or client secret that has appeared in one. ## The root fix is upstream Redacting in the client works around the library, and it works for the shapes known today. The complete fix is in `grpc`: implementing `format_status/1` on `StreamResponseProcess`, `ConnectionProcess` and `GRPC.Client.Connection` so request headers are redacted from crash reports, and not crashing on a malformed `grpc-status`. That change belongs to elixir-grpc. When a release contains it, the client’s filter becomes a second layer rather than the only one. ## Optional: also protect other Mint users The client’s filter is credential-aware. It does not hide the crashes of other processes in your application that use Mint, such as an HTTP client with its own secrets. If you want one filter for all of them, install your own primary filter that replaces matching reports with a notice, and keep it separate from the client’s: ```elixir defmodule MyApp.MintCrashReportFilter do @moduledoc false @notice "Report of a Mint process crash suppressed: it can contain credentials" def filter(%{msg: {:report, report}} = event, _extra) do if mentions_mint?(report), do: %{event | msg: {:string, @notice}}, else: :ignore end def filter(_event, _extra), do: :ignore defp mentions_mint?(atom) when is_atom(atom), do: atom |> Atom.to_string() |> String.starts_with?("Elixir.Mint.") defp mentions_mint?(%{} = map), do: map |> Map.to_list() |> mentions_mint?() defp mentions_mint?(tuple) when is_tuple(tuple), do: tuple |> Tuple.to_list() |> mentions_mint?() defp mentions_mint?([head | tail]), do: mentions_mint?(head) or mentions_mint?(tail) defp mentions_mint?(_other), do: false end ``` ```elixir :ok = :logger.add_primary_filter( :my_app_mint_crashes, {&MyApp.MintCrashReportFilter.filter/2, []} ) ``` Two blunter settings also keep reports out, at a cost: `config :logger, handle_otp_reports: false` drops every OTP report including those of your own processes, and a Logger `level` of `:critical` drops every `:error` log. # Resilience and the connection lifecycle > How the Elixir client connects, registers artifacts, detects a dead session, reconnects, and what your code sees while it does. A Chronicle client is a small supervision tree of processes that all depend on one live connection to the kernel. Reactors, reducers, projections, seeders, webhooks and subscriptions all need that connection, and they all need it to come *back* after it drops. Instead of letting each of them retry on its own, which races and registers observers before the kernel knows their definitions, the client drives every part through one connection lifecycle. The design follows the .NET client’s connection lifecycle. This page explains that lifecycle and, more importantly for your code, what calls return while it is not ready. ## The three phases | Phase | Meaning | | --- | --- | | `:disconnected` | No live session with the kernel. | | `:connected` | The session handshake completed: the kernel acknowledged the client’s connection id with its first keepalive. The channel can carry calls. | | `:registered` | The client has registered the event store, namespace, event types, constraints, read models and projections. Observers may now attach, and seeders run. | Splitting `:connected` from `:registered` keeps observers from racing ahead of their server-side definitions. A reducer’s observation stream can’t be registered until the kernel knows about the reducer’s read model, so reactors and reducers wait for `:registered`, never merely `:connected`. ``` session handshake artifacts registered session lost session lost disconnected connected registered ``` ## Wait for readiness in your code `Chronicle.Client` starts, and returns from `Supervisor.start_link/2`, long before the lifecycle reaches `:registered`. What a call does in that window depends on the call: * Appends, event log reads and most other calls use the gRPC channel as soon as it exists. Before that, and while the client reconnects, they return `{:error, :not_connected}` instead of waiting. Once the channel is up they are sent even if registration hasn’t finished, so an append can reach the kernel before your event types are registered. * Read model queries in `Chronicle.ReadModels`, including `Chronicle.read_model/3`, first wait up to 30 seconds for `:registered`, then retry briefly while the kernel settles. Block until the client is ready with `Chronicle.Connections.Lifecycle.wait_until/3`: ```elixir alias Chronicle.Connections.Lifecycle case Lifecycle.wait_until(Lifecycle.name_for(Chronicle.Client), :registered, 30_000) do :ok -> :ready {:error, :timeout} -> :not_ready end ``` `Lifecycle.name_for/1` takes the client’s `name:`, which defaults to `Chronicle.Client`. To follow phase changes instead of blocking, call `Lifecycle.subscribe/1`. It returns `{:ok, phase, connection_id}` with the current phase and then sends `{:chronicle_lifecycle, phase, connection_id}` messages on every transition. `Lifecycle.phase/1` returns the current phase. ## What happens on connect On every connect, first or not, the client runs the same ordered registration: 1. Ensure the event store and namespace exist. 2. Register event types, including their migrations. 3. Register constraints. 4. Register read models. 5. Register projections, including the read-model definitions of reducers. 6. Advance to `:registered`. 7. Reactors and reducers open their observation streams, webhook and subscription registrars register, and seeders run. Steps 1 to 5 finish before step 7, so observers never register against definitions that don’t exist yet. If any registration step fails, the client logs `Chronicle registration failed` with the reason, stays in `:connected`, and retries every five seconds. A registration that never succeeds, such as a rejected event type schema, therefore shows up as a client that never reaches `:registered`. Check the log for that message. ## What happens on disconnect The kernel sends the client a keepalive every second, and the client answers each one. When no keepalive arrives for five seconds, or the session stream ends, the client treats the session as dead: 1. The lifecycle moves to `:disconnected`. 2. Reactors and reducers close their observation streams and wait. 3. The connection id is rotated, so the next session uses a fresh identity. 4. The client dials a fresh channel and runs the connect sequence above again. The channel reconnects with exponential backoff, starting at one second and capped at ten. Tune it with the `:reconnect_base_delay` and `:reconnect_max_delay` client options, in milliseconds. The client keeps retrying for as long as it runs. The `:retry_attempts` option is accepted for backward compatibility and ignored; reconnection doesn’t give up. Reactors register as replayable, and projections and reducers are rewindable. When an observer re-attaches, Chronicle resumes it from the last event it handled, so events appended while it was detached are delivered then, not skipped. ## Calls during an outage Appends and queries are not queued or retried while the client is disconnected. Treat them like any other remote call: * Expect `{:error, :not_connected}` while the client has no channel, and decide whether to retry, fail the request, or wait with `Lifecycle.wait_until/3`. * A call that races a dying channel returns `{:error, %GRPC.RPCError{status: 14}}` (unavailable). A reply the gRPC transport can’t read, such as the kernel rejecting an unauthenticated call, returns `{:error, %GRPC.RPCError{status: 13}}`. Version 3.5.0 raised or exited in the caller in both cases. * A successful `:ok` means Chronicle stored the event. An error from a call that timed out doesn’t prove the write failed; see [Event sequences](/chronicle/clients/elixir/event-sequences/#appending-and-waiting-for-observer-completion). ## Seeding waits for registration Seeders run each time the lifecycle reaches `:registered`: at startup and again after every reconnect. A failed seeding run is retried every five seconds. The kernel remembers which seed entries it applied, so running it again after a reconnect applies nothing new. See [Seeding](/chronicle/clients/elixir/seeding/#how-it-runs). ## Failure isolation Each reactor and reducer manages its own observation stream. If one stream fails while the connection is healthy, only that stream reopens, after a short delay. A failing observer never takes down the connection or its siblings. Failures inside your handlers are reported to Chronicle as [failed partitions](/chronicle/clients/elixir/failed-partitions/). ## What you manage You don’t write reconnect code. The client re-establishes the session, re-registers artifacts and re-attaches observers on its own. What you do own is how your code behaves while the client is not `:registered`: wait for readiness before the first call, and handle `{:error, reason}` from every call. ## Awaiting and inspecting registration Registration retries on its own, but your code often needs to know when it is done: ```elixir :ok = Chronicle.Client.await_registration() # waits up to 30 seconds {:error, {:failed, status}} = Chronicle.Client.await_registration() # the kernel rejected something for good %{state: :registering, attempts: 2, error: error, next_retry_in: ms} = Chronicle.Client.registration_status() ``` `registration_status/1` reports `:pending`, `:registering`, `:registered` or `:failed`. A rejection the kernel will never accept - a validation failure, an invalid argument - is `:failed` at once and is not retried; anything else is retried with backoff. Tune it with the client’s `:registration` option: ```elixir {Chronicle.Client, registration: [initial_delay: 1_000, max_delay: 30_000, multiplier: 2, jitter: 0.2, max_attempts: 10]} ``` The default keeps the historical fixed five-second delay and never gives up. ## Expired and rotated tokens The client attaches a fresh bearer token to every call and refreshes it ahead of expiry. If the kernel answers a call with `Unauthenticated` - it restarted with new signing keys, say - the client drops its cached token and repeats that call once with a new one, instead of presenting the rejected token until it expires. Every reconnect also starts from a fresh token. ## Watchdog ```elixir {Chronicle.Client, watchdog: [unregistered_for: 60_000]} ``` Reconnects a client that has been stuck short of `:registered` for that long (default 60 seconds when enabled with `watchdog: true`). It replaces a hand-written supervisor-of-the-client watchdog. ## Silent observers A kernel can evict a client’s connection while appends keep working: observers stop receiving events and nothing fails. Watch their [lag](/chronicle/clients/elixir/observers/#lag) with `observer_lag: [interval: 15_000]` and the `[:chronicle, :observer, :lag]` telemetry event. # Constraints > Constraints are shared Chronicle behavior. The shared docs cover constraint concepts, model-bound constraints, declarative constraints, and client-tabbed… Constraints are shared Chronicle behavior. The shared docs cover constraint concepts, model-bound constraints, declarative constraints, and client-tabbed examples. * [Constraints](/chronicle/constraints/) * [Understanding constraints](/chronicle/understanding-constraints/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Scoping uniqueness checks `unique/2` accepts a `:scope` option that narrows a uniqueness check to values observed at append time along one or more dimensions, instead of checking globally across the whole event store. Mirrors the C# client’s `IConstraintBuilder.PerEventSourceType()` / `PerEventStreamType()` / `PerEventStreamId()`. `:scope` takes an atom — `:per_event_source_type`, `:per_event_stream_type`, or `:per_event_stream_id` — or a list of them to scope along more than one dimension at once: ```elixir defmodule MyApp.Events.EmailChanged do use Chronicle.Events.EventType, id: "email-changed" defstruct [:email] # Unique only among events from the same event source type — the same email value # may repeat across different event source types (e.g. an employee and a customer # can share an email without tripping this constraint). unique(:email, scope: :per_event_source_type) end ``` Without `:scope`, uniqueness is checked globally, as before. ## Unique constraint options `unique/2` and `unique_event_type/1` (model-bound, declared as an event type attribute) and `unique/2`/`unique_event_type/1,2` (standalone, see below) share the same option set: | Option | Applies to | Meaning | | --- | --- | --- | | `:name` | both | the constraint name; required on a standalone declaration, defaults to the first field (`unique/2`) or the event type id (`unique_event_type/1`) on a model-bound one | | `:mode` | `unique/2` only | `:per_event_source` (default) or `:per_value` — see *Unique modes* below | | `:ignore_casing` | `unique/2` only | compare values case-insensitively | | `:message` | both | a string, or a `{Module, :function}` callback — see *Violation messages* below | | `:scope` | both | narrows uniqueness checking to values observed at append time along one or more dimensions (`:per_event_source_type`, `:per_event_stream_type`, `:per_event_stream_id`), mirroring the C# client’s `IConstraintBuilder.PerEventSourceType()`/`PerEventStreamType()`/`PerEventStreamId()`. Defaults to unscoped (checked globally) | | `:event_sequences` | both | restrict the declaration to specific event sequences (default: all) | An unknown option, an invalid `:mode`, or an invalid `:scope` value emits a compile warning naming the module and the option. The declaration still compiles: an unknown `:mode` raises `ArgumentError` when the constraint definition is built, and an unknown option or invalid `:scope` is ignored, as in earlier releases. ## Unique modes `unique :code, mode: :per_value` keeps every value until a removal event releases it. The default, `mode: :per_event_source`, keeps one value per event source and frees the previous one when it is replaced. ## Releasing a held value In `:per_value` mode, `remove_constraint/1` releases every value a removal event’s event source held. `remove_constraint/2` with `:properties` releases only the value the removal event itself carries, in the same order as the constrained properties: ```elixir defmodule MyApp.Events.MappingVersionRegistered do use Chronicle.Events.EventType, id: "mapping-version-registered" defstruct version_id: "" unique(:version_id, name: "UniqueMappingVersionId", mode: :per_value) end defmodule MyApp.Events.MappingVersionRemoved do use Chronicle.Events.EventType, id: "mapping-version-removed" defstruct version_id: "" remove_constraint("UniqueMappingVersionId", properties: [:version_id]) end ``` ## Standalone constraint modules `unique/2` and `unique_event_type/1` declared as event type attributes cover a constraint that belongs to exactly one event type’s own module. `use Chronicle.Events.Constraint` is for a constraint spanning several event types under one name, or one defined apart from any single event type’s module — mirroring the C# client’s `IConstraint`/`IConstraintBuilder`: ```elixir defmodule MyApp.Constraints.UniqueEmail do use Chronicle.Events.Constraint unique name: "UniqueEmail" do on MyApp.Events.UserRegistered, [:email] on MyApp.Events.UserEmailChanged, [:new_email] removed_with MyApp.Events.UserRemoved end end ``` `on/2` declares one of the participating event types and its properties; call it more than once to constrain several event types under one name. `removed_with/1,2` inside a `unique/2` block releases the value, optionally with `properties:` (as above, in `:per_value` mode); after a bare `unique_event_type/1,2` it releases that constraint instead — calling it before any `unique_event_type/1,2` has been declared in the module is a compile-time error. Two `unique_event_type/1,2` declarations sharing a `:name` merge into one mutually-exclusive constraint — at most one event drawn from the named set is ever appended for the same event source: ```elixir defmodule MyApp.Constraints.PersonTerminalOutcome do use Chronicle.Events.Constraint unique_event_type MyApp.Events.PersonAliasedTo, name: "PersonTerminal" unique_event_type MyApp.Events.PersonErased, name: "PersonTerminal" end ``` Standalone constraint modules are discovered automatically (like event types and read models) or registered explicitly with the `:constraints` client option: ```elixir {Chronicle.Client, connection_string: "chronicle://localhost:35000", event_store: "my-store", constraints: [MyApp.Constraints.UniqueEmail]} ``` ## Violation messages and details `:message` accepts a string or a `{Module, :function}` callback, invoked with the violation when an append is rejected. The violation’s `:details` carry the keys Chronicle itself assigns a meaning to; `Chronicle.Events.ConstraintViolation.details/1` maps them onto atoms — `:property_name`, `:property_value`, `:closed_by`, `:closed_at_sequence_number` — leaving any other key as-is: ```elixir defmodule MyApp.Constraints.Messages do alias Chronicle.Events.ConstraintViolation def project_name_taken(violation) do "A project named '#{ConstraintViolation.details(violation)[:property_value]}' already exists." end end defmodule MyApp.Events.ProjectCreated do use Chronicle.Events.EventType, id: "project-created" defstruct name: "" unique(:name, name: "UniqueProjectName", message: {MyApp.Constraints.Messages, :project_name_taken}) end ``` ## Closing events An event that ends something declares `closes_stream`. Appending it closes its stream scope; every later event inside that scope is refused, events elsewhere are not. Mirrors .NET’s `[ClosesStream]`: ```elixir defmodule MyApp.Events.PeriodClosed do use Chronicle.Events.EventType, id: "period-closed" defstruct period: "" closes_stream event_stream_id_from: :period, reopened_by: [MyApp.Events.PeriodReopened] end ``` Options: `:dimensions` (`:event_source_id`, `:event_source_type`, `:event_stream_type`, `:event_stream_id`), `:event_stream_id_from` (the event field that names the stream), `:reopened_by` (event types that lift the closure of exactly that scope), `:name` and `:event_sequences`. Declarations that share a `:name` merge, and must agree on dimensions and stream id field. A registration closes nothing in history; only later appends close scopes. Inspect and operate on closed scopes with `Chronicle.EventSequences.Streams`. # Context management: correlation, identity, and causation > Set process-scoped correlation ids, identities and causation chains that the Elixir client records with every append. Every event Chronicle stores records which operation it belonged to, who caused it, and the chain of steps that led to it. [Correlation, identity, and causation](/chronicle/concepts/correlation-identity-causation/) explains what the three mean and why Chronicle tracks them. This page covers how you set them from Elixir. All three live in the calling process’s dictionary. Set them once at the start of a request or job, and every append from that process carries them. You can override each one on a single call. Because they are process-scoped, they don’t follow work into spawned processes; see [Async/spawn considerations](#asyncspawn-considerations). ## Correlation IDs A correlation id groups the events that one user action or job produced, so you can trace it across services. ### Creating Correlation IDs ```elixir alias Chronicle.Correlation.CorrelationId # A new random UUID-based correlation id correlation_id = CorrelationId.create() # Wrap an id you received from elsewhere, such as an incoming request header correlation_id = CorrelationId.new("92a130f7-16e2-44f7-a8e3-79e76f5df3e1") ``` ### Process-Scoped Correlation Set a correlation id for the process, and every following append includes it: ```elixir alias Chronicle.Correlation.CorrelationId Chronicle.set_correlation_id(CorrelationId.create()) :ok = Chronicle.append("account-42", %MyApp.Events.AccountOpened{owner: "Alice", balance: 500}) :ok = Chronicle.append("account-42", %MyApp.Events.FundsDeposited{amount: 100}) # Read the current correlation id current = Chronicle.current_correlation_id() # Replace it with a newly generated id before the next operation Chronicle.clear_correlation_id() ``` `set_correlation_id/1` also accepts a plain string. Two details matter when you rely on the value: * When no correlation id is set, every call to `current_correlation_id/0`, and every append, gets a **new** random id. Two appends only share a correlation id if you set one. * `clear_correlation_id/0` doesn’t remove the id. It stores and returns a freshly generated one. ### One-Off Correlation Override Override the process-scoped correlation id for a single append: ```elixir :ok = Chronicle.append("account-42", event, correlation_id: "92a130f7-16e2-44f7-a8e3-79e76f5df3e1" ) ``` ## Identity Identity answers *who caused this state change?* Chronicle stores it with every event, which gives you a permanent record of authorship for audits, debugging, and “last changed by” views. An identity is a `%Chronicle.Identity{}` with four fields: | Field | Description | | --- | --- | | `subject` | Stable unique identifier, such as a user id from your identity provider | | `name` | Human-readable display name | | `user_name` | Login or user name. Defaults to `""`. | | `on_behalf_of` | The identity this one acts for, or `nil`. See [On-behalf-of / delegation chains](#on-behalf-of--delegation-chains). | ### Creating Identity ```elixir alias Chronicle.Identity identity = Identity.new("user-42", "Alice Cooper", "alice") ``` Chronicle also provides three well-known identities: ```elixir Identity.system() # automated processes and background work Identity.not_set() # identity was deliberately not provided Identity.unknown() # identity information could not be determined ``` When a process hasn’t set an identity, appends record `Identity.system()`. Set a real identity for anything a person caused, or their actions are recorded as system actions. ### Process-Scoped Identity Set the identity for the process, and every following append records it: ```elixir alias Chronicle.Identity Chronicle.set_identity(Identity.new("user-42", "Alice Cooper", "alice")) :ok = Chronicle.append("account-42", %MyApp.Events.AccountOpened{owner: "Alice", balance: 500}) :ok = Chronicle.append("account-42", %MyApp.Events.FundsDeposited{amount: 100}) current = Chronicle.current_identity() Chronicle.clear_identity() ``` ### One-Off Identity Override Override the process-scoped identity for a single append: ```elixir identity = Identity.new("batch-processor", "Batch Processor", "batch") :ok = Chronicle.append("account-42", event, identity: identity) ``` ### Switching Identity When one process serves several users, such as a CLI tool, a test harness or an admin console, switch the identity between operations. Chronicle records the identity in effect at each append: ```elixir alice = Identity.new("u-alice", "Alice Smith", "alice.smith") bob = Identity.new("u-bob", "Bob Jones", "bob.jones") # Alice approves a leave request Chronicle.set_identity(alice) :ok = Chronicle.append(request_id, %MyApp.Events.LeaveApproved{approved_by: "u-alice"}) # Bob then archives it Chronicle.set_identity(bob) :ok = Chronicle.append(request_id, %MyApp.Events.RequestArchived{}) ``` The event log shows Alice caused the approval and Bob caused the archival. ### On-Behalf-Of / Delegation Chains When a system actor performs an action for a person, pass the person as the fourth argument, `on_behalf_of`: ```elixir alias Chronicle.Identity human = Identity.new("u-alice", "Alice Smith", "alice.smith") system_acting = Identity.new("s-workflow", "Workflow Engine", "workflow-engine", human) Chronicle.set_identity(system_acting) :ok = Chronicle.append("order-99", %MyApp.Events.OrderFulfilled{}) ``` Chronicle records the full chain: the workflow engine, acting on behalf of Alice. `Identity.without_duplicates/1` removes repeated subjects from a long chain. ## Causation Chains Causation records the steps that led to an event: a root, such as an incoming HTTP request, followed by the commands or messages that triggered the change. Chronicle stores the chain with every event, so you can follow an event back to where it started. ### Building Causation Chains ```elixir alias Chronicle.Auditing.CausationManager # The root: the action that started the chain CausationManager.define_root(%{application: "banking-api", version: "1.0"}) # The command this request executes CausationManager.add("Banking.Commands.OpenAccount", %{account_id: "account-42"}) # Each append stores the full chain :ok = Chronicle.append("account-42", %MyApp.Events.AccountOpened{owner: "Alice", balance: 500}) # Remove the root and chain before the next operation CausationManager.clear() ``` ### Causation Entries Each step is a `Chronicle.Auditing.CausationEntry` with a type and a map of properties. The type identifies the operation, typically the full name of a command or message: ```elixir alias Chronicle.Auditing.CausationManager CausationManager.define_root(%{request_id: "req-1", source: "my-api"}) CausationManager.add("MyApp.Commands.PlaceOrder", %{order_id: "order-99"}) # A second step triggered by the first CausationManager.add("MyApp.Commands.ReserveInventory", %{sku: "ABC123", qty: 2}) ``` Properties become part of the permanent event log. Don’t put secrets or personal data in them. ### One-Off Causation Override Pass a causation list for a single append: ```elixir alias Chronicle.Auditing.CausationEntry causation = [ CausationEntry.new("MyApp.Commands.InitialRequest", %{request_id: "req-1"}), CausationEntry.new("MyApp.Commands.ProcessRequest", %{item_id: "item-1"}) ] :ok = Chronicle.append("resource-42", event, causation: causation) ``` ## Typical Usage Pattern Set the context at the boundary of each request or job, and reset it when the work ends, whether it succeeded or not. Some web servers reuse one process for several requests on a connection, so context left behind can leak into the next request. This Phoenix controller action is an excerpt: it assumes an authentication plug has put `:current_user` and `:request_id` in `conn.assigns`. ```elixir defmodule MyAppWeb.AccountController do use MyAppWeb, :controller alias Chronicle.{Auditing.CausationManager, Correlation.CorrelationId, Identity} def create(conn, %{"account_id" => account_id, "owner" => owner}) do user = conn.assigns.current_user Chronicle.set_correlation_id(CorrelationId.create()) Chronicle.set_identity(Identity.new(user.id, user.name, user.user_name)) CausationManager.clear() CausationManager.define_root(%{request_id: conn.assigns.request_id}) CausationManager.add("MyApp.Commands.OpenAccount", %{account_id: account_id}) try do case Chronicle.append(account_id, %MyApp.Events.AccountOpened{owner: owner, balance: 0}) do :ok -> send_resp(conn, 201, "") {:error, reason} -> send_resp(conn, 422, inspect(reason)) end after Chronicle.clear_correlation_id() Chronicle.clear_identity() CausationManager.clear() end end end ``` ## Async/Spawn Considerations Context lives in the process dictionary, so spawned processes, including `Task` processes, don’t inherit it. Capture it before spawning and set it again inside: ```elixir alias Chronicle.Auditing.CausationManager correlation_id = Chronicle.current_correlation_id() identity = Chronicle.current_identity() Task.start(fn -> Chronicle.set_correlation_id(correlation_id) Chronicle.set_identity(identity) CausationManager.define_root(%{source: "background-task"}) Chronicle.append("account-42", %MyApp.Events.FundsDeposited{amount: 100}) end) ``` ## See Also * `Chronicle.Correlation.CorrelationId`: correlation id value type * `Chronicle.Correlation.CorrelationIdManager`: process-scoped correlation handling * `Chronicle.Identity`: identity value type, well-known identities, and `on_behalf_of` chains * `Chronicle.Identity.IdentityProvider`: process-scoped identity handling * `Chronicle.Auditing.CausationManager`: process-scoped causation chains * `Chronicle.Auditing.CausationEntry`: individual causation steps * `Chronicle.Auditing.CausationType`: built-in causation entry types # Discovery and artifact identity drift > See what Chronicle.Elixir discovered at startup, and catch an artifact identity that silently drifted from the kernel. `Chronicle.Client` discovers your event types, reactors, reducers, projections, read models, constraints, webhooks, event store subscriptions, seeders, migrations, global handlers and captures at startup — whether through `otp_app:` auto-discovery, loaded-module discovery, or explicit module lists. This page covers two things that build on that discovery: a report of what was found, and a check for likely artifact identity drift before registration. ## The discovery report `Chronicle.Client` builds a `%Chronicle.Discovery.Report{}` during `init/1`, logs a one-line summary at `:info` and the full report at `:debug`, and exposes it afterwards: ```elixir report = Chronicle.Client.discovery_report() Chronicle.Discovery.Report.format(report) ``` ```text Chronicle discovery report: event types (2): account-opened (gen 1), funds-deposited (gen 1) event sources (0) reactors (1): notification-reactor reducers (1): account-reducer projections (0) read models (1): AccountSummary constraints (0) webhooks (0) event store subscriptions (0) seeders (0) migrations (0) global handlers (0) captures (0) ``` `Chronicle.Artifacts.report/1` builds the same report directly from an OTP application, without starting a client — `Chronicle.Artifacts.discover/1` keeps returning its plain map unchanged, so existing callers are unaffected: ```elixir report = Chronicle.Artifacts.report(:my_app) Chronicle.Discovery.Report.count_of(report, :reactor) ``` `mix chronicle.discover --otp-app my_app` prints the same report from the command line — see below. ## Defaulted ids are a rename hazard A reactor, reducer or declarative projection with no explicit `:id` defaults it to the module name (see [Reactors](/chronicle/clients/elixir/reactors/#pinning-the-id), [Reducers](/chronicle/clients/elixir/reducers/#pinning-the-id), [Projections](/chronicle/clients/elixir/projections/#pinning-the-id)). Renaming or moving that module then changes its id, and Chronicle treats it as a different artifact — its observation position silently resets. Discovery warns about this once per module, for the life of the BEAM node (not once per client start, so it doesn’t repeat every time the report is rebuilt): ```text [warning] MyApp.Reactors.NotificationReactor has no explicit :id and defaults to its module name. Renaming or moving this module will silently reset its observation position. Pin the id with `use Chronicle.Reactors.Reactor, id: "...", ...`. ``` Pass `warn_defaulted_ids: false` to `Chronicle.Client` to suppress it. ## Artifact identity drift Before registering, `Chronicle.Client` can compare what it is about to register with what the kernel already has, and flag three kinds of drift: * **`:schema_changed_without_generation`** — an event type generation’s schema no longer matches what the kernel has recorded for it. Comparison is by coarse shape (property names, nesting, and type category), so reordering keys or widening `integer` to `number` isn’t drift, but an added, removed or retyped field is. * **`:observer_missing_from_code`** — the kernel has a client-owned reactor, reducer, or projection/read-model observer on the `event-log` sequence whose id no longer appears in your current code. This finding is always `:info`, for every observer type: an event store can be shared by several clients, and the observers another client registered are client-owned too, so it is evidence only. It is logged at info level and never fails startup under `drift_policy: :fail`. Observers on other event sequences are not compared. * **`:observer_event_types_changed`** — a still-declared observer’s handled event types no longer match what the kernel has recorded for it. Control this with the `:drift_policy` client option: ```elixir {Chronicle.Client, connection_string: "chronicle://localhost:35000", event_store: "my-store", otp_app: :my_app, drift_policy: :warn} ``` * `:ignore` — skip the check and its kernel calls entirely. * `:warn` (default) — log any findings and register anyway. * `:fail` — log findings and refuse to register when a `:warning`-severity finding exists (a changed event type schema or a reactor whose handled event types changed). `:info` findings, including every `:observer_missing_from_code`, never block registration under any policy. If the check itself cannot complete (the kernel is unreachable, an exception, or the 5 second deadline passes), `:warn` logs and continues and `:fail` stops — an indeterminate result is never treated the same as a clean one. A drift finding under `:fail` is not retried, because the same code drifts the same way on every attempt. The check costs one `AllEventTypes` call, plus one `AllEventTypeGenerations` call for each event type whose local generation is not the latest the kernel has (a few in parallel). It runs once per client start; registration retries reuse its result. The kernel enforces some of this on its own A schema change without a bumped generation is also rejected by the kernel’s own `RegisterEventTypes` validation, independent of `:drift_policy`. `:warn` still reaches that rejection (and registration still fails, for the kernel’s own reason); `:fail` stops first, for your own reason — useful when you want the clearer, earlier message. Read the most recent findings at any time: ```elixir Chronicle.Client.identity_drift() # => [%Chronicle.Discovery.Drift{kind: :reactor, id: "notification-reactor", # reason: :observer_missing_from_code, severity: :info}] ``` ## `mix chronicle.discover` Prints the discovery report from the command line, optionally checking it against a running kernel: ```bash mix chronicle.discover --otp-app my_app mix chronicle.discover --otp-app my_app \ --connection-string "chronicle://localhost:35000" --fail-on-drift ``` * `--otp-app` — **(required)** the OTP application to discover artifacts from. * `--connection-string` — compare the report with a kernel’s event types and observers (event store `"default"`, namespace `"Default"` — the same defaults `Chronicle.Client` uses). * `--fail-on-drift` — exit `1` when a `:warning`-severity finding exists; has no effect without `--connection-string`. Exit codes: `0` printed cleanly with no drift (or none requested), `1` drift found with `--fail-on-drift`, `2` could not run (missing `--otp-app`, or the kernel couldn’t be reached). # Dynamic Consistency Boundary > Guard a decision with a decision read so a concurrent append can never invalidate it silently. Dynamic Consistency Boundary (DCB) in Chronicle, decision reads, and event sources and the DCB are documented as shared Chronicle concepts. Read those first for the model and its rules. * [Dynamic Consistency Boundary in Chronicle](/chronicle/dynamic-consistency-boundary/) * [Decision reads](/chronicle/read-models/decision-reads/) * [Event sources and the Dynamic Consistency Boundary](/chronicle/event-sources/dcb/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Decision reads in Elixir `Chronicle.ReadModels.DecisionReads` folds a read model at a key, in a fresh session, and carries forward the exact event-log position the fold stopped at. `Chronicle.Transactions.UnitOfWork` uses that position to guard a subsequent append: if anything else appends a matching event to the guarded event source between the read and the commit, the kernel rejects the whole batch and nothing is appended. ```elixir Chronicle.decide(fn _unit_of_work -> {:ok, read} = Chronicle.ReadModels.DecisionReads.get(MyApp.ReadModels.Seat, "seat-5") if read.instance == nil do Chronicle.EventSequences.EventLog.append("seat-5", %SeatBooked{by: "alice"}) end end) ``` `Chronicle.decide/2` returns `{:ok, result}` when the commit succeeds, or `{:error, %Chronicle.ReadModels.DecisionConflict{}}` when a decision read enrolled inside the function conflicted with a concurrent append — the kernel rejected the commit, so nothing from this attempt was written. Re-read and decide again: ```elixir case Chronicle.decide(fn _unit_of_work -> {:ok, read} = Chronicle.ReadModels.DecisionReads.get(MyApp.ReadModels.Seat, "seat-5") if read.instance == nil, do: Chronicle.EventSequences.EventLog.append("seat-5", %SeatBooked{by: "alice"}) end) do {:ok, _} -> :booked {:error, %Chronicle.ReadModels.DecisionConflict{}} -> :retry {:error, reason} -> {:error, reason} end ``` To reject a decision, return `{:error, reason}` from the function. `Chronicle.decide/2` then rolls the unit of work back, so nothing the function appended is written, even an append made before the rejection, and returns `{:error, reason}` as-is. Any other return value is committed. A commit that fails for another reason, such as an unreachable kernel, also rolls back and returns `{:error, reason}`. Either way, once `Chronicle.decide/2` returns, its unit of work is completed and its process stopped, so it is safe to call from a long-lived process such as a GenServer or a Phoenix connection. Call `get/2` only from inside `Chronicle.decide/2` (or `Chronicle.Transactions.UnitOfWork.with_unit_of_work/2`) — it enrolls the read on the current unit of work. Call `get_detached/2` to fold the same way without committing anything against it: ```elixir {:ok, read} = Chronicle.ReadModels.DecisionReads.get_detached(MyApp.ReadModels.Seat, "seat-5") read.instance ``` ## Admission Not every read model shape can be protected. `Chronicle.ReadModels.DecisionReads.admit/2` checks a model’s shape without any I/O, mirroring what `get/2` and `get_detached/2` check before folding: ```elixir case Chronicle.ReadModels.DecisionReads.admit(MyApp.ReadModels.Seat) do {:ok, _event_types} -> :can_be_guarded {:error, reason} -> {:refused, reason} end ``` A model with a `join/2`, `removed_with_join/2`, `children/2`, or `from_every/1` declaration is refused — see `Chronicle.ReadModels.DecisionReadRefused` for every refusal reason. `get/2` and `get_detached/2` raise that exception directly rather than returning the refusal as a value, since a refused shape is a programming error to fix, not a runtime outcome to branch on. ## Guarding one event stream Pass an event source id, event stream type, and event stream id directly to guard one stream independently of the model’s own key — useful when an event source definition routes the append to a specific stream, such as a transaction log scoped to one month: ```elixir {:ok, read} = Chronicle.ReadModels.DecisionReads.get_detached( MyApp.ReadModels.MonthlyWithdrawals, "account-1", "transactions", "2025-01" ) ``` `admit_for_stream/2` checks the same shape `admit/2` does. The default stream (an empty or `"All"` event stream type) is refused with `:all_streams_not_supported` — a stream-scoped guard needs one specific stream. ## Guarding more than one event source A rule that spans two entities needs one decision read per entity, because a decision read folds one event source. Enroll both reads inside the same `Chronicle.decide/2` call and append one event to each event source; the kernel admits or rejects the whole batch atomically: ```elixir Chronicle.decide(fn _unit_of_work -> {:ok, course} = Chronicle.ReadModels.DecisionReads.get(MyApp.ReadModels.CourseSeats, course_id) {:ok, student} = Chronicle.ReadModels.DecisionReads.get(MyApp.ReadModels.StudentCourses, student_id) subscribed_courses = if student.instance, do: student.instance.subscribed, else: 0 cond do course.instance == nil -> {:error, :unknown_course} course.instance.subscribed >= course.instance.capacity -> {:error, :course_is_full} subscribed_courses >= 5 -> {:error, :too_many_courses} true -> Chronicle.EventSequences.EventLog.append(course_id, %CourseSeatTaken{student_id: student_id}) Chronicle.EventSequences.EventLog.append(student_id, %StudentJoinedCourse{course_id: course_id}) end end) ``` The `{:error, :course_is_full}` style rejections come back from `Chronicle.decide/2` unwrapped, so match `{:error, :course_is_full}` rather than `{:ok, {:error, :course_is_full}}`. If either side changed after it was read, the commit is rejected and neither event is appended. ## Passive reducers (Elixir-only) The .NET client refuses a reducer-backed read model outright (`DecisionReadRefusalReason.Reducer`). Elixir admits a **passive** reducer (`use Chronicle.Reducers.Reducer, active: false`) instead: because a passive reducer’s read model is never kept warm by an observer, its current value is always folded fresh on the client from the raw event log, which makes it trivial to capture the exact boundary the fold stopped at without a kernel-side session. An **active** reducer is still refused with `:reducer` — nothing outside the kernel knows when an actively-observed reducer reached a consistent point. ```elixir defmodule MyApp.Reducers.SeatReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.Seat, active: false @handles MyApp.Events.SeatBooked @impl true def reduce(%MyApp.Events.SeatBooked{by: by}, _model, _context), do: %MyApp.ReadModels.Seat{booked_by: by} end ``` `Chronicle.ReadModels.DecisionReads.get/2` and `get_detached/2` work the same way against `MyApp.ReadModels.Seat` whether it is backed by a projection or by this passive reducer. # Event Sequences > Event sequences are a shared Chronicle concept. Use the shared docs for the event log, custom sequences, filtering, and appending behavior. Event sequences are a shared Chronicle concept. Use the shared docs for the event log, custom sequences, filtering, and appending behavior. * [Event sequence concept](/chronicle/concepts/event-sequence/) * [Events and event logs](/chronicle/events/) * [Appending events](/chronicle/events/appending/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) The sections below cover `Chronicle.EventSequences.EventLog` APIs that are new to the Elixir client and not yet covered by the shared docs above. ## Redacting events Redaction permanently erases an event’s content, for example to honor a GDPR erasure request. It works on events, not on keys: [compliance](/chronicle/compliance/) encrypts PII and makes it unreadable by deleting a subject’s key, while redaction overwrites the stored event itself. Neither can be undone. Mirrors the C# and TypeScript clients’ `IEventSequence.Redact()`. Redact a single event by its sequence number, with a reason: ```elixir :ok = Chronicle.EventSequences.EventLog.redact(sequence_number, "Customer requested erasure") ``` Redact every event for an event source in one call — for example, to erase all data for a specific user: ```elixir :ok = Chronicle.EventSequences.EventLog.redact_for_event_source( event_source_id, "Account closed under GDPR" ) ``` Narrow a bulk redaction to specific event types by passing a list of event type modules as the third argument: ```elixir :ok = Chronicle.EventSequences.EventLog.redact_for_event_source( event_source_id, "Email address was captured in error", [MyApp.Events.EmailChanged] ) ``` Both functions accept `:client`, `:namespace` and `:event_sequence_id`, plus the `:correlation_id`, `:identity` and `:causation` context overrides described in [Context management](/chronicle/clients/elixir/context/). They ignore the other `append/3` options, such as `:tags`, `:subject` and `:concurrency_scope`. ## Reading forward from a sequence number `get_from_sequence_number/2` returns events from (and including) a given sequence number onward, optionally filtered by event source id and event types. Mirrors the C# and TypeScript clients’ `GetFromSequenceNumber()`. ```elixir {:ok, events} = Chronicle.EventSequences.EventLog.get_from_sequence_number(sequence_number, event_source_id: event_source_id, event_types: [MyApp.Events.OrderPlaced] ) ``` The list holds the generated contract structs, `%Cratis.Chronicle.Contracts.Sequences.AppendedEventResponse{}`, not your event structs. Each carries its metadata in `Context` and the event’s JSON in its content field. ## Sequence numbers `get_tail_sequence_number/2` returns the sequence number of the last appended event. `get_next_sequence_number/2` mirrors it but returns the number that will be assigned to the **next** appended event — it distinguishes “the log is empty” (returns `0`) from “the tail is at sequence `0`” (returns `1`), which `get_tail_sequence_number/2` alone cannot do. Mirrors the C# and TypeScript clients’ `GetNextSequenceNumber()`. ```elixir {:ok, tail} = Chronicle.EventSequences.EventLog.get_tail_sequence_number(event_source_id) {:ok, next} = Chronicle.EventSequences.EventLog.get_next_sequence_number(event_source_id) ``` Leaving `:event_source_type` and `:event_stream_type` out looks across every source and stream type. Version 3.5.0 narrowed an omitted value to `"Default"` and returned `{:ok, 0}` however many events were stored; upgrade to 3.5.1 or later. `get_tail_sequence_number_for_observer/2` scopes the tail lookup to only the event types a reactor or reducer module subscribes to (its `@handles` declarations), instead of every event type. Mirrors the C# client’s `GetTailSequenceNumberForObserver()`. ```elixir {:ok, tail} = Chronicle.EventSequences.EventLog.get_tail_sequence_number_for_observer( MyApp.Reactors.OrderNotifier ) ``` ## Filtering by stream `get_for_event_source/2` and `get_tail_sequence_number/2` also accept `:event_source_type`, `:event_stream_type`, and `:event_stream_id` options, narrowing either call to a specific non-default stream instead of silently ignoring these dimensions: ```elixir {:ok, events} = Chronicle.EventSequences.EventLog.get_for_event_source(event_source_id, event_stream_type: "audit-trail", event_stream_id: "2024" ) ``` ## Completing a stream `complete_stream/3` closes a named, non-default stream so that no further events can be appended to it. The default stream can never be completed. ```elixir case Chronicle.EventSequences.EventLog.complete_stream("audit-trail", "2024") do {:ok, _tail_sequence_number} -> :ok {:error, :default_stream_cannot_be_completed} -> :error {:error, :already_completed} -> :ok {:error, reason} -> {:error, reason} end ``` The last clause covers transport errors such as `{:error, :not_connected}`. ## Appending and waiting for observer completion `append_and_wait_for_completion/3` appends a single event and then waits (default: 5 seconds) for every observer affected by the append — reactors, reducers, projections — to either reach the appended sequence number or fail, instead of returning as soon as the event is durably stored. Mirrors the C# client’s `AppendResult.WaitForCompletion()`. ```elixir case Chronicle.EventSequences.EventLog.append_and_wait_for_completion( event_source_id, %MyApp.Events.OrderPlaced{customer_id: customer_id, total: total} ) do {:ok, %{success: true, failed_partitions: []}} -> :ok {:ok, %{success: false, failed_partitions: _failed_partitions}} -> # One or more observers failed on the event — inspect failed_partitions # (a list of `Chronicle.FailedPartitions.FailedPartition`). :error {:error, reason} -> # The append failed, or it succeeded and the wait failed. See below. {:error, reason} end ``` Pass `:timeout` (milliseconds) to override the default wait. Read the three outcomes carefully, because an error doesn’t always mean the event wasn’t stored: * `{:ok, %{success: true}}`: the event is stored and every affected observer has processed it. * `{:ok, %{success: false, failed_partitions: ...}}`: the event is stored, but at least one observer failed on it. * `{:error, reason}`: either the append failed, or the append succeeded and the wait itself failed. A wait that runs past the gRPC deadline returns `{:error, %GRPC.RPCError{status: 4, message: "Deadline Exceeded"}}` even though the event is stored. Don’t retry the append blindly on `{:error, _}`; check the event log or use a [concurrency scope](/chronicle/events/concurrency/) so a retry can’t store the event twice. There is no append-many variant: waiting after `append_many/3` isn’t supported. ## Append results `append/3` and `append_many/3` return `:ok` or `{:error, reason}`. When you need the sequence number that was assigned, or the *structured* reason an append was refused, use the `_with_result` variants. They return a `Chronicle.EventSequences.AppendResult`; mirrors the .NET `AppendResult` and `AppendManyResult`. ```elixir case Chronicle.append_with_result("user-1", %MyApp.Events.UserRegistered{email: "a@b.c"}) do {:ok, %{sequence_number: sequence_number}} -> {:registered, sequence_number} {:error, %{constraint_violations: [violation | _]}} -> {:rejected, violation.constraint_name, violation.message} {:error, %{concurrency_violations: [violation | _]}} -> {:conflict, violation.expected_sequence_number, violation.actual_sequence_number} {:error, other} -> {:error, other} end ``` `append_many_with_result/3` returns every assigned sequence number in order. A rejection is `{:error, %AppendResult{}}` carrying `:constraint_violations` (each with `:constraint_name`, `:message`, `:event_type_id`, `:sequence_number` and `:details`), `:concurrency_violations` (each with `:event_source_id`, `:expected_sequence_number` and `:actual_sequence_number`) and `:errors`. Transport failures stay plain `{:error, reason}` terms. To wait until the observers have processed what you appended, call `Chronicle.EventSequences.AppendResult.wait_for_completion/2` on a successful result. ## Reading typed events `get_for_event_source/3` and `get_from_sequence_number/2` return the raw wire events. Pass `decode: true` to get `Chronicle.Events.AppendedEvent` structs instead, whose `:event` is the event struct you appended: ```elixir {:ok, events} = Chronicle.EventSequences.EventLog.get_for_event_source("acc-1", decode: true) for %{event: %MyApp.Events.FundsDeposited{amount: amount}, context: context} <- events do IO.puts("#{amount} deposited by #{context.caused_by.name} at sequence #{context.sequence_number}") end ``` An event of a type this client does not know has `event: nil` and the decoded payload in `:raw`; a redacted event has `redacted?: true` and a `:redaction` with the reason. Nested structs, lists of structs and maps decode as described in `Chronicle.Serialization`. ## Event context Reactors, reducers and `decode: true` reads all give you a `Chronicle.Events.EventContext`: the event type and source, sequence number, `occurred`, event store and namespace, `correlation_id`, the identity that caused the event (`caused_by`, with `on_behalf_of`), the `causation` chain, `tags`, `hash`, `subject` and `appended_generation` (the generation that was actually appended, regardless of which generation a reactor or reducer is written against; `0` means unknown). See [Context management](/chronicle/clients/elixir/context/) for how to set the correlation id, identity and causation on the way in. ## Creation-only guards `Chronicle.Events.ConcurrencyScope.expecting_no_matching_event/1` refuses an append when a matching event already exists - the guard for “this can only happen once”: ```elixir scope = Chronicle.Events.ConcurrencyScope.expecting_no_matching_event( event_source_id: true, event_types: [MyApp.Events.UserRegistered] ) Chronicle.append("user-1", %MyApp.Events.UserRegistered{}, concurrency_scope: scope) ``` It sends the kernel’s explicit no-matching-event condition. Leaving the expected sequence number unset is not a substitute: that turns the check off. `expecting_no_matching_event/1` also accepts an existing scope, so it composes with the other pipe-able builders below. ## Concurrency `Chronicle.Events.ConcurrencyScope` offers pipe-able builder helpers in addition to `new/2` and `for_event_source/2`: `with_sequence_number/2`, `with_event_source/1`, `with_event_stream_type/2`, `with_event_stream_id/2`, `with_event_source_type/2`, `with_event_type/2`, `with_event_types/2`, and `expecting_no_matching_event/1` (on a struct as well as on keyword options): ```elixir scope = 12 |> ConcurrencyScope.new() |> ConcurrencyScope.with_event_source() |> ConcurrencyScope.with_event_stream_type("Transactions") |> ConcurrencyScope.with_event_type(MyApp.Events.FundsDeposited) ``` `ConcurrencyScope.at_tail/2` reads the current tail - narrowed by `:event_stream_type`, `:event_stream_id`, `:event_source_type` and `:event_types` the same way the returned scope narrows - and returns a scope expecting it, in one call: ```elixir {:ok, scope} = ConcurrencyScope.at_tail("account-1", event_stream_type: "Transactions") Chronicle.append("account-1", %MyApp.Events.FundsDeposited{amount: 10}, concurrency_scope: scope) ``` ### Default concurrency strategy By default a client sends no concurrency constraint for an append that declares none and that no event source definition derives one for. Opt a client into deriving one automatically with the `:concurrency` option on `Chronicle.Client`: ```elixir {Chronicle.Client, connection_string: "chronicle://localhost:35000", event_store: "my-store", concurrency: [default: :optimistic, check_first_append: false]} ``` With `default: :optimistic`, an append that has neither an explicit nor a definition-derived scope reads the tail (narrowed exactly the way the append itself is routed) and expects nothing to have been appended since - mirroring the .NET client’s `OptimisticConcurrencyStrategy`. `check_first_append: true` additionally guards the first append into an empty narrowing (an empty event source, or the first event under a new stream type/id) with `expecting_no_matching_event/1` instead of leaving it unchecked. An explicit or definition-derived scope on an append always wins over this default. ### Duplicate explicit scopes in a batch The wire protocol carries one concurrency scope per event source id in a batch. Two *unequal* explicit scopes declared for the same event source id in one `append_many_for_event_sources/2` call (or one unit of work) are rejected before anything is sent: ```elixir {:error, {:duplicate_concurrency_scope, event_source_id}} ``` Equal duplicates (the same guard declared more than once) are accepted and collapse to a single entry. ## Streams, metadata and verification `Chronicle.EventSequences.Streams` closes, reopens and inspects stream scopes, reads event metadata and verifies stored content: ```elixir alias Chronicle.EventSequences.Streams {:ok, sequence_number} = Streams.complete_scope(event_source_id: "acc-1", event_stream_type: "Period", event_stream_id: "2024") {:ok, true} = Streams.scope_completed?(event_source_id: "acc-1", event_stream_type: "Period", event_stream_id: "2024") {:ok, closed} = Streams.closed(event_source_id: "acc-1") :ok = Streams.reopen_scope("Late invoice arrived", event_source_id: "acc-1", event_stream_type: "Period", event_stream_id: "2024") {:ok, [metadata]} = Streams.get_metadata([sequence_number]) {:ok, metadata} = Streams.get_metadata_at(sequence_number) {:ok, :equal} = Streams.verify_content(sequence_number, %MyApp.Events.PeriodClosed{period: "2024"}) ``` Events that close a scope as part of their own append are declared with `closes_stream`; see [Constraints](/chronicle/clients/elixir/constraints/#closing-events). `get_metadata_at/2` reads a single event’s metadata by sequence number, returning `{:ok, metadata}` or `{:error, :not_found}`. `get_metadata/2` accepts up to 500 sequence numbers, de-duplicating them before the request is sent; above that it returns `{:error, {:too_many_event_locators, 500}}` without sending one. Metadata also carries `:initiator_type` (`:unknown | :user | :agent | :system`) and `:resolved_caused_by` - a `Chronicle.EventSequences.ResolvedIdentity` carrying the same subject/name/user name as `:caused_by`, plus `:resolution` (how the kernel resolved the identity at read time: `:resolved`, `:name_unavailable`, `:missing`, `:not_set` or `:system`) and its own `:on_behalf_of` chain. `:caused_by` (a plain `Chronicle.Identity`) also carries `:on_behalf_of` now. ## Prepared events `EventLog.prepare/2` serializes an event once and returns a `Chronicle.EventSequences.PreparedEvent` bound to the client, namespace and event sequence that prepared it. Reuse it with `EventLog.append_prepared/3` and `Streams.verify_content/3` without re-serializing the event - useful when you need to verify stored content against exactly what you intended to send, or when preparing is itself expensive (large payloads, custom serialization): ```elixir {:ok, prepared} = EventLog.prepare(%MyApp.Events.PeriodClosed{period: "2024"}) :ok = EventLog.append_prepared("acc-1", prepared) {:ok, :equal} = Streams.verify_content(sequence_number, prepared) ``` Appending a prepared event through a different client, namespace, or event sequence than the one that prepared it returns `{:error, :prepared_event_belongs_to_another_sequence}`. Unlike the .NET client, single use is not enforced - a prepared event can be appended more than once, exactly like appending the same event struct twice. ## Unknown event types Every append path (`append/3`, `append_many/3`, the `_with_result` variants, `append_many_for_event_sources/2`, `EventLog.prepare/2`) and `Streams.verify_content/3` check that an event struct `use`s `Chronicle.Events.EventType` before building a request, returning `{:error, {:unknown_event_type, module}}` instead of raising deep inside request building: ```elixir defmodule MyApp.NotAnEvent do defstruct [:value] end {:error, {:unknown_event_type, MyApp.NotAnEvent}} = Chronicle.append("acc-1", %MyApp.NotAnEvent{value: 1}) ``` ## Payload size The kernel refuses a message larger than 4 MiB. Sending one used to break the connection every observer shares, so the client now checks first and returns `{:error, {:payload_too_large, size, limit}}`. A reducer whose read model outgrows the limit reports the reduction as failed instead. Raise or disable the guard with `config :cratis_chronicle, max_payload_bytes: 8 * 1024 * 1024` (or `:infinity`) if your kernel is configured for larger messages. # Event sources and streams > Declare named event sources and streams, register them at startup, and append through them from Elixir. An event source definition gives appends a stable name, optional named streams and default concurrency dimensions. The definition belongs to the append, not to the event type: the same event type can be appended through different event sources. Definitions are optional. Appends that don’t name an event source behave exactly as before and carry no registered-source metadata. Registered definitions need Chronicle 19.30.0 or newer. ## Declare a definition ```elixir defmodule MyApp.AccountEventSource do use Chronicle.EventSources.EventSource, name: "Account", description: "A bank account", concurrency: [:event_source_id] stream "Transactions", description: "Money movements", concurrency: [:event_source_id, :event_stream_id] end ``` `:name` defaults to the module’s last segment without a trailing `EventSource`. Concurrency dimensions are any of `:event_source_id`, `:event_source_type`, `:event_stream_type` and `:event_stream_id`. A stream that declares none inherits the dimensions of its source. A module that declares the same stream name twice fails to compile. ## Register `Chronicle.Client` discovers event source modules together with event types and registers them on every (re)connect, right after event types and before any observer starts. You can also pass them explicitly: ```elixir {Chronicle.Client, connection_string: "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000", event_sources: [MyApp.AccountEventSource]} ``` Two modules that use the same source name raise `Chronicle.EventSources.DuplicateEventSourceName` when the client starts. Registration is an upsert; definitions you leave out of a later registration stay registered in the kernel. ## Append through a definition ```elixir Chronicle.EventSequences.EventLog.append("account-1", %MyApp.Events.FundsDeposited{amount: 50}, event_source: MyApp.AccountEventSource, event_stream: "Transactions", event_stream_id: "2024-05" ) ``` The source name becomes the event source type and the `EventSource` metadata of the event, and the stream name becomes the event stream type. `append_many/3` and units of work take the same options. Without `:event_stream` the stream type is `"All"`. A source can be given by module or by name. A mismatch is rejected before anything is sent: | Error | Cause | | --- | --- | | `{:unknown_event_source, source}` | The source isn’t registered with the client. | | `{:event_stream_does_not_belong_to_event_source, source, stream}` | The source doesn’t declare the stream. | | `{:event_routing_contradicts_event_source, source, dimension, expected, actual}` | An explicit `:event_source_type` or `:event_stream_type` disagrees with the definition. | | `{:event_stream_without_event_source, stream}` | `:event_stream` was given without `:event_source`. | ### Mixed sources in one transaction Each `Chronicle.EventSequences.EventForEventSourceId` entry can carry its own `event_source` and `event_stream`. They win over the `:event_source` and `:event_stream` options of `append_many_for_event_sources/2`, which apply only to entries that name none. If one entry doesn’t resolve, nothing is appended. ## Concurrency When you append through a definition without a `:concurrency_scope`, the client reads the tail sequence number narrowed by the dimensions that apply (the stream’s, or the source’s if the stream declares none) and sends it as the scope. An explicit `:concurrency_scope`, including `ConcurrencyScope.none()`, always wins and no read is made. With no dimensions declared, no scope is sent. In a batch, every guarded entry of one event source id must agree on its guard: the dimensions and the source type, stream type and stream id they select. Entries with the same effective guard share one scope, and entries without dimensions need none and never suppress a later guarded entry. Chronicle accepts one concurrency scope per event source id, so a batch whose derived guards differ for one id (for example two different stream ids, or two definitions with different dimensions) fails with `{:incompatible_concurrency_scopes, event_source_id, guards}` before anything is read or sent. Pass an explicit `:concurrency_scope` on an entry of that id to choose one guard for it, or append the entries in separate batches. ## Event context Reactor and reducer contexts have an `:event_source` key holding the name of the event source the event was appended through, or `nil` for events appended without one. # Event store subscriptions > Import selected events from another event store's outbox with discoverable or imperative Elixir subscriptions. Chronicle.Elixir supports event store subscriptions for importing events from one event store into another event store. This is useful when you want one Chronicle event store to consume selected events from another store’s outbox without writing a reactor or webhook. See [Explicit event store subscriptions](/chronicle/subscriptions/explicit-subscriptions/) and [Outbox/inbox](/chronicle/subscriptions/outbox-inbox/) for the underlying concepts and how they apply across every client. This page adds the Elixir-specific **discoverable subscription module** pattern (`use Chronicle.EventStoreSubscriptions.Subscription`), which isn’t covered there. ## Two ways to register subscriptions You can register subscriptions: 1. **Discoverably** with `use Chronicle.EventStoreSubscriptions.Subscription` 2. **Imperatively** with `Chronicle.EventStoreSubscriptions.subscribe/4` `Chronicle.Client` can auto-discover and register subscription modules during startup, just like it does for reactors, reducers, read models, migrations, and webhooks. ## Discoverable subscriptions Define a module like this: ```elixir defmodule MyApp.EventStoreSubscriptions.DefaultAccountEvents do use Chronicle.EventStoreSubscriptions.Subscription, source_event_store: "default" alias Chronicle.EventStoreSubscriptions.DefinitionBuilder @impl true def define(builder) do builder |> DefinitionBuilder.with_event_type(MyApp.Events.AccountOpened) |> DefinitionBuilder.with_event_type(MyApp.Events.FundsDeposited) end end ``` ### Options for `use Chronicle.EventStoreSubscriptions.Subscription` * `:source_event_store` — **required** name of the source event store to subscribe to * `:id` — optional stable identifier for the subscription. Defaults to the module name. The `define/1` callback receives a `Chronicle.EventStoreSubscriptions.DefinitionBuilder`. Return the builder after selecting event types. If you don’t select any event types, the subscription uses the event types the client was given in `:event_types` or discovered in your application. When that list is empty, for example with `discover: false` and no `:event_types`, it falls back to every event type module loaded in the VM. Select event types explicitly to control exactly what the subscription receives. ## Auto-registration with `Chronicle.Client` Start `Chronicle.Client` with discovery enabled: ```elixir children = [ {Chronicle.Client, connection_string: "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000", event_store: "target-store", otp_app: :my_app} ] ``` If your OTP application includes modules that use `Chronicle.EventStoreSubscriptions.Subscription`, the client discovers them and registers them automatically on startup. You can also register discoverable subscriptions manually: ```elixir :ok = Chronicle.register_event_store_subscription(MyApp.EventStoreSubscriptions.DefaultAccountEvents) :ok = Chronicle.register_discovered_event_store_subscriptions() ``` Both accept `client:` with the `name:` of another running client, when you start more than one. ## Imperative registration For one-off or runtime-defined subscriptions, register them directly: ```elixir :ok = Chronicle.subscribe_to_event_store( "default-account-events", "default", fn builder -> builder |> Chronicle.EventStoreSubscriptions.DefinitionBuilder.with_event_type( MyApp.Events.AccountOpened ) |> Chronicle.EventStoreSubscriptions.DefinitionBuilder.with_event_type( MyApp.Events.FundsDeposited ) end ) ``` The arguments are: * subscription id * source event store * a function that configures the definition builder * optional keyword options, such as `client:` with the name of a non-default client ## Removing subscriptions Remove a subscription by identifier: ```elixir :ok = Chronicle.unsubscribe_from_event_store("default-account-events") ``` ## Listing subscriptions `Chronicle.EventStoreSubscriptions.get_all/1` returns every event store subscription currently registered with the target event store. Mirrors the C# and Kotlin clients’ `IEventStoreSubscriptions.GetAll()`. ```elixir {:ok, subscriptions} = Chronicle.EventStoreSubscriptions.get_all() Enum.each(subscriptions, fn subscription -> IO.puts("#{subscription.id} <- #{subscription.source_event_store}") end) ``` Each subscription is returned as a `%Chronicle.EventStoreSubscriptions.Definition{}` with: * `:id` — the subscription’s identifier * `:source_event_store` — the event store it imports events from * `:event_types` — the `%Chronicle.EventStoreSubscriptions.EventType{}` list it subscribes to ## Definition builder `Chronicle.EventStoreSubscriptions.DefinitionBuilder` currently supports: * `new/1` * `with_event_type/2` * `build/3` Example: ```elixir alias Chronicle.EventStoreSubscriptions.DefinitionBuilder builder = DefinitionBuilder.new([MyApp.Events.AccountOpened, MyApp.Events.FundsDeposited]) |> DefinitionBuilder.with_event_type(MyApp.Events.AccountOpened) ``` If `with_event_type/2` is never called, `build/3` uses all registered event types passed to `new/1`. ## Client options `Chronicle.Client` accepts: * `:event_store_subscriptions` — explicit list of discoverable subscription modules * `:discover` — enables or disables auto-discovery * `:otp_app` — limits discovery to modules that belong to one OTP app Explicit subscriptions are merged with discovered ones. ## Notes * Event store subscriptions are registered against the client’s target event store. * The source event store is defined per subscription. * `:namespace` is accepted in the API option list for consistency with other Chronicle APIs, but it is not currently used by the subscription registration request. * The client can add, remove and list subscriptions. There is no separate update call. # Event store discovery > List the event stores on a Chronicle kernel and the namespaces in an event store from the Elixir client. Chronicle Elixir provides APIs to query information about available event stores and their namespaces from the Chronicle kernel. This is useful for administrative tasks, multi-tenant applications, and debugging. See [Event Store](/chronicle/concepts/event-store/) and [Namespaces](/chronicle/concepts/namespaces/) for what these concepts mean and how they relate to multi-tenancy — this page covers the Elixir-specific discovery APIs for listing and verifying them at runtime. ## Getting Event Stores Query all event store names from the Chronicle kernel: ```elixir {:ok, stores} = Chronicle.get_event_stores() # => {:ok, ["my-app", "crm", "analytics"]} ``` This is useful in multi-tenant or multi-application scenarios where you need to discover which event stores are available: ```elixir case Chronicle.get_event_stores() do {:ok, stores} -> IO.puts("Available event stores: #{inspect(stores)}") {:error, reason} -> IO.puts("Failed to get event stores: #{inspect(reason)}") end ``` ## Getting Namespaces Query all namespaces within an event store: ```elixir # Use the configured client's event store {:ok, namespaces} = Chronicle.get_namespaces() # => {:ok, ["tenant-1", "tenant-2", "tenant-3"]} # Specify a different event store {:ok, namespaces} = Chronicle.get_namespaces(event_store: "analytics") ``` This is especially useful in multi-tenant applications: ```elixir defmodule MyApp.Tenants do def list_all_tenants() do case Chronicle.get_namespaces() do {:ok, tenant_ids} -> tenant_ids {:error, _} -> [] end end def migrate_all_tenants(migration_func) do Enum.each(list_all_tenants(), fn tenant_id -> IO.puts("Migrating tenant: #{tenant_id}") migration_func.(tenant_id) end) end end ``` ## Typical Usage ### Multi-Tenant Admin Interface ```elixir defmodule MyApp.Admin.Dashboard do alias MyApp.Tenants def get_system_stats() do with {:ok, stores} <- Chronicle.get_event_stores(), {:ok, namespaces} <- Chronicle.get_namespaces() do %{ event_stores: stores, current_store_namespaces: namespaces, total_tenants: Enum.count(namespaces) } else {:error, reason} -> {:error, reason} end end def list_tenant_info() do with {:ok, tenant_ids} <- Chronicle.get_namespaces() do Enum.map(tenant_ids, fn tenant_id -> %{ id: tenant_id, event_store: "my-app", namespace: tenant_id } end) else {:error, reason} -> {:error, reason} end end end ``` ### Verifying Environment Before Operations ```elixir defmodule MyApp.Operations do def safe_append(tenant_id, event) do with {:ok, namespaces} <- Chronicle.get_namespaces() do if Enum.member?(namespaces, tenant_id) do Chronicle.append("entity-123", event, namespace: tenant_id) else {:error, :tenant_not_found} end end end def safe_read(tenant_id, entity_id) do with {:ok, namespaces} <- Chronicle.get_namespaces() do if Enum.member?(namespaces, tenant_id) do Chronicle.read_model(MyApp.ReadModels.Entity, entity_id, namespace: tenant_id) else {:error, :tenant_not_found} end end end end ``` ### Multi-Store Operations `get_event_stores/1` lists every event store on the kernel, but reading from one still goes through a client. The `:client` option on reads and appends names a running `Chronicle.Client`, by the `name:` it was started with; it doesn’t accept an event store name. To work with several event stores, start one named client per store and pass that name: ```elixir children = [ Supervisor.child_spec( {Chronicle.Client, name: :orders, connection_string: connection_string, event_store: "orders"}, id: :orders ), Supervisor.child_spec( {Chronicle.Client, name: :billing, connection_string: connection_string, event_store: "billing"}, id: :billing ) ] Supervisor.start_link(children, strategy: :one_for_one) ``` Each client needs its own child id, because every `Chronicle.Client` child spec defaults to the same id. Once the clients are running, pass the name to target one: ```elixir {:ok, orders} = Chronicle.all(MyApp.ReadModels.Order, client: :orders) ``` Namespaces are different: `get_namespaces/1` takes an `:event_store` option directly, so one client can inspect any store: ```elixir defmodule MyApp.MultiStore do def namespace_counts do with {:ok, stores} <- Chronicle.get_event_stores() do Enum.map(stores, fn store_name -> case Chronicle.get_namespaces(event_store: store_name) do {:ok, namespaces} -> %{store: store_name, namespace_count: length(namespaces)} {:error, reason} -> %{store: store_name, error: reason} end end) end end end ``` ## Options Both `get_event_stores/1` and `get_namespaces/1` accept options: * `:client` — the client name (default: `Chronicle.Client`) * `:event_store` — for `get_namespaces/1`, the event store to query (defaults to the configured client’s event store) ```elixir # Use a named client Chronicle.get_event_stores(client: :my_app_client) # Query a specific event store's namespaces Chronicle.get_namespaces(event_store: "crm") # Both Chronicle.get_namespaces(client: :analytics, event_store: "analytics") ``` ## Error Handling Both functions return `{:ok, list}` on success or `{:error, reason}` on failure. `{:error, :not_connected}` means the client hasn’t connected yet or is reconnecting; see [Resilience](/chronicle/clients/elixir/connections/resilience/). ```elixir case Chronicle.get_namespaces() do {:ok, namespaces} -> IO.puts("Namespaces: #{inspect(namespaces)}") {:error, :not_connected} -> IO.puts("Chronicle is not connected yet") {:error, reason} -> IO.puts("Error: #{inspect(reason)}") end ``` Passing a `:client` name that no running client uses raises `ArgumentError` (`no persistent term stored with this key`) instead of returning an error tuple. ## See Also * `Chronicle.EventStores` — low-level event store discovery * `Chronicle` — high-level API * [Get started](/chronicle/clients/elixir/get-started/): install the client and start `Chronicle.Client` * `Chronicle.Client`: client configuration options * [Event Store](/chronicle/concepts/event-store/) and [Namespaces](/chronicle/concepts/namespaces/) — the shared concepts behind event stores and namespace-scoped tenancy # Events > Events and event logs are shared Chronicle topics. Use the shared docs for event modeling, event types, appending, querying, and evolution. Events and event logs are shared Chronicle topics. Use the shared docs for event modeling, event types, appending, querying, and evolution. * [Events and event logs](/chronicle/events/) * [Event concept](/chronicle/concepts/event/) * [Appending events](/chronicle/events/appending/) * [Event evolution](/chronicle/understanding-event-evolution/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Event type options ```elixir use Chronicle.Events.EventType, id: "customer-registered", generation: 1, subject: :customer_id, # the field naming the data subject (see below) tags: ["billing"], # tags every append of this event carries tombstone: true, # the last event of an event source's life public: true, # visible to other event stores' subscriptions compensation_for: MyApp.Events.CustomerRegisteredInError, types: [addresses: {:list, MyApp.Address}] ``` `subject:` resolves the data subject of an append from the event itself, so the right erasure key protects the PII even when the person is not the event source. An explicit `subject:` option on the append wins; without either, the kernel uses the event source id. `tombstone`, `public` and `compensation_for` mirror .NET’s `[Tombstone]`, `[Public]` and `[CompensationFor]`. `public:` makes the event type visible to other event stores through event store subscriptions; it does not change who can append to or read from this event store directly. ## A previous generation: `generation_for:` A module that represents an **older** generation of an existing event type declares `generation_for:` instead of its own `:id`. The event type id is resolved from the target module, so the two generations can never drift apart by a hand-typed, mismatched id string: ```elixir defmodule MyApp.Events.CustomerRegistered do use Chronicle.Events.EventType, id: "customer-registered", generation: 2 defstruct customer_id: "", full_name: "" end defmodule MyApp.Events.CustomerRegisteredV1 do use Chronicle.Events.EventType, generation_for: MyApp.Events.CustomerRegistered, generation: 1 defstruct customer_id: "", owner_name: "" end ``` `generation_for:` requires an explicit `generation:` (the generation this module represents) and cannot be combined with `:id` - a module is either the current event type or a previous generation of one, not both. See [Migrations](/chronicle/clients/elixir/migrations/) for bridging generations with an `Chronicle.Events.Migration`. ## Validation errors A few event type and migration mistakes are checked before the client connects. The client logs the full error and continues by default; set `strict_validation: true` on `Chronicle.Client` to raise instead, so a bad set fails immediately rather than being retried by the registration coordinator. `Chronicle.Events.EventTypes.validate/2` returns `:ok | {:error, exception}` and `validate!/2` raises: * `Chronicle.Events.MultipleEventTypesWithSameIdFound` - two modules represent the same generation of the same event type id. Each generation must be represented by exactly one module. * `Chronicle.Events.MultipleMigratorsForSameEventTypeGeneration` - more than one `Chronicle.Events.Migration` module bridges the same pair of generations for an event type. Exactly one migrator may bridge a pair of generations. * `ArgumentError` - a migration’s `:from` and `:to` resolve to different event type ids. Mark the previous generation with `generation_for:` instead of giving it a separate `:id`. * `Chronicle.Events.EventTypeGenerationForCannotBeCombinedWithEventType` - a module gives both `:id` and `:generation_for`. * `Chronicle.Events.EventTypeGenerationReferencesNonEventType` - `generation_for:` names a module that doesn’t `use Chronicle.Events.EventType`. ## Maps and nested structs Event payloads are written with camelCase field names at every depth, and the schema is generated the same way, so a nested struct’s `series_name` arrives as `seriesName`. A plain map field (`parameters: %{}`) is an object whose keys are *data* and are never transformed. Declare fields that default to `nil` or hold lists of structs with `types:`. See `Chronicle.Serialization`. # External services > Register HTTP and database external service definitions with Chronicle from the Elixir client. `Chronicle.ExternalServices` provides an idiomatic Elixir API for registering external service definitions with Chronicle. External services let the Chronicle kernel talk to systems outside the event store — an HTTP API or a MsSql/PostgreSql database — under a name other kernel features (such as [captures](/chronicle/clients/elixir/captures/)) can reference. They are usually configured in the Workbench, but the Elixir client also exposes a programmatic API. ## Registering an HTTP service Register an HTTP endpoint with bearer-token authentication and a custom header: ```elixir :ok = Chronicle.ExternalServices.register("CustomersApi", fn builder -> builder |> Chronicle.ExternalServices.DefinitionBuilder.http("https://api.example.com") |> Chronicle.ExternalServices.DefinitionBuilder.with_bearer_token(token) |> Chronicle.ExternalServices.DefinitionBuilder.with_header("X-Tenant", "acme") end) ``` `configure` receives a `Chronicle.ExternalServices.DefinitionBuilder` and must return it (or a continuation of it) once the endpoint is configured. ## Registering a database service Register a PostgreSQL database endpoint: ```elixir :ok = Chronicle.ExternalServices.register("CustomersDb", fn builder -> Chronicle.ExternalServices.DefinitionBuilder.postgre_sql( builder, "db.example.com", "customers", "postgres", password, 5432 ) end) ``` Use `Chronicle.ExternalServices.DefinitionBuilder.ms_sql/6` instead for a Microsoft SQL Server endpoint — it takes the same host, database, username, password, and port arguments. ## Builder API `Chronicle.ExternalServices.DefinitionBuilder` is immutable and pipeline-friendly. ```elixir builder |> DefinitionBuilder.http("https://api.example.com") |> DefinitionBuilder.with_basic_auth("user", "password") |> DefinitionBuilder.with_bearer_token("token") |> DefinitionBuilder.with_oauth("https://login.example.com", "client-id", "client-secret") |> DefinitionBuilder.with_header("x-tenant", "acme") ``` * `http/2` configures an HTTP endpoint. * `ms_sql/6` and `postgre_sql/6` configure a database endpoint — mutually exclusive with `http/2` and each other; the last call wins. * `with_basic_auth/3`, `with_bearer_token/2`, and `with_oauth/4` are mutually exclusive with each other and only meaningful for HTTP endpoints. * `with_header/3` adds or replaces an HTTP header sent with every request. * `with_option/3` adds a provider-specific option to a database endpoint’s connection configuration. ## Using a named client ```elixir :ok = Chronicle.ExternalServices.register( "CustomersApi", fn builder -> Chronicle.ExternalServices.DefinitionBuilder.http(builder, "https://api.example.com") end, client: :bank_chronicle ) ``` # Failed partitions > Find the event sources where a reactor or reducer failed, and read each failed attempt, from Elixir. `Chronicle.FailedPartitions` provides an idiomatic Elixir API for inspecting Chronicle’s failed partitions. A partition (an event source, within an observer’s subscription) is marked failed when an observer can’t handle one of its events. In the Elixir client that happens when: * a reactor’s `handle/2` returns `{:error, reason}`, raises, or returns a side effect that can’t be appended; * a reducer’s `reduce/3` raises. A reducer must return the new model; returning `{:error, reason}` doesn’t mark a failure, so raise instead. A failed partition stops advancing while the rest of the observer continues. Mirrors the C# and Kotlin clients’ `IFailedPartitions`. ## Starting point Start `Chronicle.Client` first: ```elixir children = [ {Chronicle.Client, connection_string: "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000", event_store: "banking", otp_app: :my_app} ] ``` ## Getting all failed partitions Get every failed partition for any observer (reactor, reducer, and so on) on the current event store: ```elixir {:ok, failed_partitions} = Chronicle.FailedPartitions.get_all() ``` ## Getting failed partitions for a specific observer Narrow the lookup to a single observer by its id: ```elixir {:ok, failed_partitions} = Chronicle.FailedPartitions.get_for("MyApp.Reactors.OrderNotifier") ``` ## The `FailedPartition` and `Attempt` structs Each failed partition is returned as `%Chronicle.FailedPartitions.FailedPartition{}` with: * `:id` * `:observer_id` * `:partition` — the event source id that failed * `:attempts` — the list of failed attempts, oldest first Each attempt is a `%Chronicle.FailedPartitions.Attempt{}` with: * `:occurred` * `:sequence_number` * `:messages` * `:stack_trace` ```elixir Enum.each(failed_partitions, fn failed_partition -> IO.puts("#{failed_partition.observer_id}: #{failed_partition.partition}") Enum.each(failed_partition.attempts, fn attempt -> IO.puts(" ##{attempt.sequence_number}: #{Enum.join(attempt.messages, "; ")}") end) end) ``` ## Using a named client ```elixir {:ok, failed_partitions} = Chronicle.FailedPartitions.get_all(client: :bank_chronicle) ``` See [Observers](/chronicle/clients/elixir/observers/) for the observer’s own state — including removing one whose declaring code is gone, which also clears any failed partitions kept for it. # Get started with the Elixir client > Add cratis_chronicle to a Mix project, connect to a local development kernel, append an event, and read the projected read model. In this guide you build a small Mix application that records a bank account being opened and reads the account back as a read model. Along the way you learn the three things that trip up most first runs: the development credentials, waiting for the client to finish registering, and the short gap before a projection catches up. By the end, `iex -S mix` shows your read model coming back from Chronicle: ```text {:ok, %MyApp.ReadModels.Account{id: "account-1", owner: "Alice", balance: 500}} ``` ## Prerequisites * **Elixir 1.18 or later.** The client’s `grpc` dependency pulls in `googleapis 0.1.0`, which requires Elixir `~> 1.18`. CI builds and tests the client with Elixir 1.19.5 on Erlang/OTP 28.5. * **Docker**, to run the Chronicle development kernel. * Basic familiarity with Mix projects and supervision trees. ## Start Chronicle Pull the current development image first, then start it. The client talks to the kernel over gRPC contracts that change between kernel releases, so an old cached image can reject a freshly installed client (see [Troubleshooting](#troubleshooting)). ```shell docker pull cratis/chronicle:latest-development docker run -d --name chronicle \ -p 127.0.0.1:35000:35000 \ -p 127.0.0.1:27017:27017 \ cratis/chronicle:latest-development ``` The image bundles MongoDB. The `127.0.0.1:` prefixes publish both ports on this machine only: the development workbench accepts well-known credentials and the bundled MongoDB has no authentication, so keep them off shared networks. Wait until the kernel reports `Healthy`: ```shell curl --insecure --fail --retry 30 --retry-all-errors --retry-delay 1 https://localhost:35000/health ``` [Run Chronicle locally](/chronicle/get-started/choose-hosting-model/#run-chronicle-locally) covers the other hosting options. ## Install Create a supervised Mix project: ```shell mix new my_app --sup cd my_app ``` Add the client to `deps` in `mix.exs`: ```elixir defp deps do [ {:cratis_chronicle, "~> 3.5"} ] end ``` Then fetch it: ```shell mix deps.get ``` The client requires Elixir 1.18 or later and uses `grpc ~> 1.0`, Mint 1.11 or later, and `cratis_chronicle_contracts ~> 19.19`. If your application connects to gRPC separately, [select the Mint adapter for those connections](/chronicle/clients/elixir/connections/connection-strings/#tls) or add Gun as a direct dependency; grpc 1.x makes Gun optional. ## Define an event An event is a struct that uses `Chronicle.Events.EventType`. The `id:` option is required and is the event type’s stable identity, so pick it once and never change it. lib/my_app/events/account_opened.ex ```elixir defmodule MyApp.Events.AccountOpened do use Chronicle.Events.EventType, id: "account-opened" defstruct owner: "", balance: 0 end ``` Give every field a default of the type it holds. The client generates the JSON schema Chronicle validates appends against from these defaults. A field declared as `defstruct [:balance]` has a `nil` default, is registered as a string, and appending an integer to it fails with a schema violation. ## Define a read model A read model is a struct that uses `Chronicle.ReadModels.ReadModel`. Its `from/2` declarations become a projection that runs inside Chronicle, so you don’t write any update code yourself. lib/my_app/read_models/account.ex ```elixir defmodule MyApp.ReadModels.Account do use Chronicle.ReadModels.ReadModel defstruct id: "", owner: "", balance: 0 from MyApp.Events.AccountOpened, set: [id: :event_source_id] end ``` `owner` and `balance` have the same names on the event and the read model, so they are mapped automatically. The only explicit mapping stores the event source id, the account id you append to, in `id`. When names differ, map the field yourself: `set: [owner: :owner_name]` reads the event’s `owner_name` field. ## Start the client Add `Chronicle.Client` to your application’s supervision tree in `lib/my_app/application.ex`: ```elixir defmodule MyApp.Application do @moduledoc false use Application @impl true def start(_type, _args) do children = [ {Chronicle.Client, connection_string: connection_string(), event_store: "my-app", otp_app: :my_app} ] Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) end # Development-only credentials that the local development kernel accepts. # Set CHRONICLE_CONNECTION_STRING for any other environment. defp connection_string do System.get_env( "CHRONICLE_CONNECTION_STRING", "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000" ) end end ``` `otp_app: :my_app` tells the client to discover the event types, read models, reactors, reducers and seeders defined in your application. It also creates the `my-app` event store the first time it connects. The connection string carries the development client id and secret. The development kernel requires an authenticated client, and unlike the .NET client, the Elixir client doesn’t fill in these defaults when you leave the credentials out. `chronicle://localhost:35000` on its own connects, but never finishes registering. [Connection strings](/chronicle/clients/elixir/connections/connection-strings/) covers API keys, TLS validation and the other options. ## Wait until the client is registered The client connects in the background. It first opens a session, then registers your event types, read models and projections, and only then reports itself ready. That takes a few seconds. An append made before the connection is up returns an error instead of waiting, and one made before registration finishes can reach the kernel before your event type is registered: ```elixir {:error, :not_connected} ``` Wait for the `:registered` phase before your first call: ```elixir alias Chronicle.Connections.Lifecycle :ok = Lifecycle.wait_until(Lifecycle.name_for(Chronicle.Client), :registered) ``` `wait_until/3` returns `{:error, :timeout}` if registration doesn’t finish within 30 seconds, which is the default. Pass a timeout in milliseconds as the third argument to change it. In a real application, gate the work that needs Chronicle on this call rather than sleeping. ## Append an event Start an interactive session with your application running: ```shell iex -S mix ``` Wait for registration, then append: ```elixir iex> alias Chronicle.Connections.Lifecycle iex> Lifecycle.wait_until(Lifecycle.name_for(Chronicle.Client), :registered) :ok iex> Chronicle.append("account-1", %MyApp.Events.AccountOpened{owner: "Alice", balance: 500}) :ok ``` `"account-1"` is the event source id: the account this fact belongs to. `Chronicle.append/3` returns `:ok` once Chronicle has stored the event, or `{:error, reason}`. The common reasons are: | Result | Meaning | | --- | --- | | `{:error, :not_connected}` | The client isn’t connected or registered yet. | | `{:error, {:constraint_violations, violations}}` | The event failed schema validation or a uniqueness constraint. Each violation has a `Message`. | | `{:error, {:concurrency_violations, violations}}` | A concurrency scope you passed didn’t match the stored events. | | `{:error, {:incompatible_server, response}}` | The kernel and the resolved contracts package don’t match. | Match on these instead of asserting `:ok` in production code. ## Read it back Read the account back by its key: ```elixir iex> Chronicle.read_model(MyApp.ReadModels.Account, "account-1") {:ok, %MyApp.ReadModels.Account{id: "account-1", owner: "Alice", balance: 500}} ``` Chronicle projects events into read models asynchronously. `:ok` from `append/3` means the event is stored, not that the projection has run, so a read immediately afterwards can still return `{:ok, nil}`. The same `{:ok, nil}` comes back for a key that has no events at all. When the next step needs the updated read model, you can append with `Chronicle.EventSequences.EventLog.append_and_wait_for_completion/3` instead. It waits, five seconds by default, for every affected observer to catch up: ```elixir iex> Chronicle.EventSequences.EventLog.append_and_wait_for_completion( ...> "account-2", ...> %MyApp.Events.AccountOpened{owner: "Bob", balance: 250} ...> ) {:ok, %{success: true, failed_partitions: []}} iex> Chronicle.read_model(MyApp.ReadModels.Account, "account-2") {:ok, %MyApp.ReadModels.Account{id: "account-2", owner: "Bob", balance: 250}} ``` An `{:error, reason}` from this function doesn’t always mean the event wasn’t stored: the wait itself can fail after a successful append. Don’t repeat the append blindly; see [Appending and waiting for observer completion](/chronicle/clients/elixir/event-sequences/#appending-and-waiting-for-observer-completion). You now have a supervised client appending events and reading a projected read model. ## Troubleshooting **The client never reaches `:registered`, and the log repeats `Chronicle session dropped: :stream_ended, reconnecting...`.** The connection string has no credentials. Use the development credentials shown above, or the credentials your kernel is configured with. **Every append returns `{:error, {:incompatible_server, ...}}`.** `mix deps.get` resolved a newer `cratis_chronicle_contracts` than your kernel understands; the response lists the kernel’s version. Pull the current `latest-development` image and recreate the container. `cratis_chronicle` accepts any contracts version, and the contracts package reports itself as `0.1.0` locally, so you can’t pin it with a version requirement in `mix.exs`. Commit `mix.lock` to keep the resolved contracts version fixed, and move the kernel and the lock file together. **An append returns `{:error, {:constraint_violations, [...]}}` with `expected string but got number`.** A field on the event has no typed default. Give it one, such as `balance: 0`, and use a new event type id or a fresh event store, because the schema registered for the old id doesn’t change. **The read model is `{:ok, nil}` or has empty fields.** Check, in order: the projection hasn’t caught up yet (use `append_and_wait_for_completion/3`); the key you read with is the event source id you appended to; a field whose name differs between event and read model needs an explicit `set:` mapping, as described in [Define a read model](#define-a-read-model). **A call raises `ArgumentError` with `no persistent term stored with this key`.** No client with that name is running. Functions that take a `:client` option look it up by the `name:` the client was started with, which defaults to `Chronicle.Client`. ## Where to next * [Connections](/chronicle/clients/elixir/connections/) for connection strings, TLS, credentials and how the client recovers from dropped connections. * [Reactors](/chronicle/reactors/getting-started/) and [Reducers](/chronicle/reducers/getting-started/) to react to events and build read models in your own process. The shared guides show Elixir tabs. * [Read models](/chronicle/read-models/) and [Projections](/chronicle/projections/) to query and shape projected state. * [Constraints](/chronicle/constraints/), [Concurrency](/chronicle/events/concurrency/) and [Transactions](/chronicle/events/transactions/) for append-time rules. * [Context management](/chronicle/clients/elixir/context/) to record who caused each event and why. * The [console sample](https://github.com/Cratis/Chronicle.Elixir/tree/main/Samples/console) for a larger, interactive application. # Jobs > List, inspect, stop, resume and delete Chronicle jobs such as replays from the Elixir client. `Chronicle.Jobs` provides an idiomatic Elixir API for inspecting and controlling Chronicle jobs. Jobs represent long-running Chronicle operations such as replays, rebuilds, and other background work performed by the Chronicle Kernel. ## Starting point Start `Chronicle.Client` first: ```elixir children = [ {Chronicle.Client, connection_string: "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000", event_store: "banking", otp_app: :my_app} ] ``` Once the client is running, use `Chronicle.Jobs`: ```elixir {:ok, jobs} = Chronicle.Jobs.all() {:ok, job} = Chronicle.Jobs.get("8a6f5a0c-0fbf-42bd-8db0-a60f9a449b11") {:ok, steps} = Chronicle.Jobs.steps("8a6f5a0c-0fbf-42bd-8db0-a60f9a449b11") ``` ## API ### List all jobs ```elixir {:ok, jobs} = Chronicle.Jobs.all() ``` Each job is returned as `%Chronicle.Jobs.Job{}` with: * `:id` * `:details` * `:type` * `:status` * `:created_at` * `:status_changes` * `:progress` ### Get one job ```elixir {:ok, job} = Chronicle.Jobs.get(job_id) ``` Returns `{:ok, nil}` when the job does not exist. ### Get job steps ```elixir {:ok, steps} = Chronicle.Jobs.steps(job_id) ``` Each step is returned as `%Chronicle.Jobs.JobStep{}`. ### Stop, resume, and delete ```elixir :ok = Chronicle.Jobs.stop(job_id) :ok = Chronicle.Jobs.resume(job_id) :ok = Chronicle.Jobs.delete(job_id) ``` ## Status values Job statuses are normalized to atoms: * `:none` * `:preparing_job` * `:preparing_steps` * `:starting_steps` * `:running` * `:completed_successfully` * `:completed_with_failures` * `:stopped` * `:failed` * `:removing` Job-step statuses are normalized to atoms: * `:unknown` * `:scheduled` * `:running` * `:completed_successfully` * `:completed_with_failure` * `:stopped` * `:failed` * `:removing` ## Using a named client ```elixir {:ok, jobs} = Chronicle.Jobs.all(client: :bank_chronicle) ``` ## Top-level convenience helpers `Chronicle` also delegates to the jobs API: ```elixir {:ok, jobs} = Chronicle.get_jobs() {:ok, job} = Chronicle.get_job(job_id) {:ok, steps} = Chronicle.get_job_steps(job_id) :ok = Chronicle.stop_job(job_id) ``` # Limits and gaps > Kernel limits to size your data against, the consistency guarantees of appends and projections, and what the Elixir client does not do yet. ## Kernel limits | Limit | Value | What happens | | --- | --- | --- | | Message size | 4 MiB per gRPC message | The client refuses larger payloads with `{:error, {:payload_too_large, size, limit}}`; a reducer whose read model exceeds it reports the reduction as failed. Configure with `config :cratis_chronicle, max_payload_bytes: ...`. | | Read model document | 4 MiB | Roughly 18,000 entries at 220-250 bytes each. A child collection that can grow without bound belongs in its own read model per child, not one document. | | Observer subscriber deadline | `observers.subscriberTimeout`, 5 seconds by default | Applies to a reactor or reducer handling one batch. Keep handlers fast, and size partitions so a backlog delivered in one call finishes within it. | | Filtered read model queries | `:max_scan`, 10,000 instances | See [Filtering and sorting](/chronicle/clients/elixir/read-models/#filtering-and-sorting): the filter is applied by the client. | ## Consistency * `Chronicle.append/3` returning `:ok` means the event is stored. Projections, reducers and reactors process it **afterwards and asynchronously**. * A read model read right after an append, including `append_many/3`, may not reflect it yet. When you need your own write, use `Chronicle.EventSequences.EventLog.append_and_wait_for_completion/3` or `AppendResult.wait_for_completion/2`, which return once the affected observers have processed the event (or failed). * `await_registration/2` returns once the client’s artifacts are registered. Observers subscribe right after; wait for the first events you depend on with the calls above rather than sleeping. * `get/3` returns `{:ok, nil}` both for a key that does not exist and for one that is not projected yet. ## Security `skipTlsValidation` defaults to `true` so that a development kernel works without setup. A connection to a remote host that skips validation logs a warning at startup. Production connection strings must say `skipTlsValidation=false`; see [Connection strings](/chronicle/clients/elixir/connections/connection-strings/#tls). ## Not supported yet * The filter of `query/2` is evaluated by the client, not the kernel. * `Chronicle.Testing.InMemoryKernel` models appends, reads, concurrency scopes and `unique`/`unique_event_type` constraints, but not `closes_stream` constraints or the read model and projection query surface. Use `Chronicle.Testing.Integration` against a running kernel for those, and note that the kernel has no way to delete the event stores those specs create. * A shared read model subscription that reconnects does not replay the changes made while it was down, because the kernel’s watch request cannot resume from a sequence number. Query again after `{:chronicle_read_model_watch_resumed, model}`. * `Chronicle.Projections.preview/2` runs a registered projection’s declaration over a stored event sequence; previewing a projection over events you supply is not possible. * Webhook sources in captures cannot carry authentication, and the kernel engine currently runs only `api` and `events` sources. * Event metadata carries no content hash or size; the hash of an event is on its `EventContext`. * Projections have no `min`/`max` operations, because the kernel has no such expressions. * Constraint drift is not detected, because the kernel cannot list registered constraints. * Trace context is not stored on events by the kernel, so observer spans are not linked to the append that caused them. See the repository’s open issues for the current list. # Migrations > Event type migrations are shared Chronicle behavior. The shared docs own the migration model and client-tabbed examples. Event type migrations are shared Chronicle behavior. The shared docs own the migration model and client-tabbed examples. * [Understanding event evolution](/chronicle/understanding-event-evolution/) * [Event type migrations concept](/chronicle/concepts/event-type-migrations/) * [Migrations](/chronicle/migrations/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Declaring a previous generation A module representing an older generation of an event type can declare `generation_for:` instead of giving itself a separate `:id`. The id is resolved from the target module, so the two generations can never drift apart by a hand-typed, mismatched id string - see [A previous generation: `generation_for:`](/chronicle/clients/elixir/events/#a-previous-generation-generation_for) on the Events page. ```elixir defmodule MyApp.Events.AuthorRegistered do use Chronicle.Events.EventType, id: "author-registered", generation: 2 defstruct [:first_name, :last_name] end defmodule MyApp.Events.AuthorRegisteredV1 do use Chronicle.Events.EventType, generation_for: MyApp.Events.AuthorRegistered, generation: 1 defstruct [:name] end defmodule MyApp.Migrations.AuthorRegisteredMigration do use Chronicle.Events.Migration, from: {MyApp.Events.AuthorRegisteredV1, generation: 1}, to: {MyApp.Events.AuthorRegistered, generation: 2} alias Chronicle.Events.MigrationBuilder @impl true def upcast(builder) do builder |> MigrationBuilder.split_property(:name, :first_name, " ", 0) |> MigrationBuilder.split_property(:name, :last_name, " ", 1) end @impl true def downcast(builder) do MigrationBuilder.combine_properties(builder, [:first_name, :last_name], :name, " ") end end ``` ## Migration checks A few mistakes are checked before the client connects. By default the client logs the full error and carries on; start it with `strict_validation: true` to raise instead, so a bad set fails immediately instead of being retried. `Chronicle.Events.EventTypes.validate/2` runs the same checks and returns `:ok` or `{:error, exception}`; `validate!/2` raises. * Two modules representing the same generation of the same event type id are reported as `Chronicle.Events.MultipleEventTypesWithSameIdFound`. * More than one migration module bridging the same pair of generations is reported as `Chronicle.Events.MultipleMigratorsForSameEventTypeGeneration` - exactly one migrator may bridge a pair of generations. * A migration whose `:from` and `:to` resolve to different event type ids raises `ArgumentError` (as in earlier releases, now with a longer message) - mark the previous generation with `generation_for:` instead of giving it a separate `:id`. * A non-adjacent generation gap (for example `from: generation: 1` to `to: generation: 3`) raises `Chronicle.Events.InvalidMigrationGenerationGap`. # Observability > OpenTelemetry spans, telemetry events, and trace propagation in the Elixir client, following the shared Cratis OpenTelemetry convention. The Elixir client instruments the operations it performs against a Chronicle kernel with [OpenTelemetry](https://opentelemetry.io) spans and `:telemetry` events, following the shared Cratis OpenTelemetry convention ([`Cratis/Architecture#33`](https://github.com/Cratis/Architecture/issues/33)). The convention keeps span, attribute, and metric names the same across every Chronicle client language, so a collector groups them together regardless of which language produced a given span. ## Always on, export is your choice Instrumentation runs whether or not an OpenTelemetry SDK is installed — there is no Chronicle-specific “enable tracing” switch. The client depends only on `opentelemetry_api`; with no SDK (the `opentelemetry` and `opentelemetry_exporter` applications) started in your release, every span and propagation call is a documented no-op and nothing leaves the process. To actually collect and export traces, add the SDK and exporter to your own application, and configure them the standard OpenTelemetry way — through `OTEL_*` environment variables or application configuration. The client adds no configuration surface of its own for this. mix.exs ```elixir {:opentelemetry, "~> 1.3"}, {:opentelemetry_exporter, "~> 1.6"} ``` config/runtime.exs ```elixir config :opentelemetry, resource: [service: [name: "my-app"]], text_map_propagators: [:trace_context] config :opentelemetry_exporter, otlp_protocol: :http_protobuf, otlp_endpoint: "http://localhost:4318" ``` ## Instrumentation scope Every span uses the `Cratis.Chronicle.Client` instrumentation scope — the same string the .NET, TypeScript, and Kotlin clients use, so `AddSource("Cratis.*")`-style collector configuration catches every Chronicle client regardless of language. ## Spans | Span name | Kind | Where | | --- | --- | --- | | `cratis.chronicle.client.event_sequence.append` | Client | `Chronicle.EventSequences.EventLog.append/3`, `append_with_result/3` | | `cratis.chronicle.client.event_sequence.append_many` | Client | `append_many/3`, `append_many_with_result/3`, `append_many_for_event_sources/2`, and a `Chronicle.Transactions.UnitOfWork.commit/1` | | `cratis.chronicle.client.reactor.handle` | Consumer | a reactor’s `handle/2` (and `handle_replay/2`) delivery | | `cratis.chronicle.client.reducer.reduce` | Consumer | a reducer’s `reduce/3` delivery | A span’s status is set to error when the wrapped operation returns `{:error, _}` (this client’s ordinary tagged-tuple failure shape), and an exception raised inside it is recorded on the span before re-raising — tracing never swallows a failure. The status description is only a fixed classification: the exception module name, or the first atom of a tagged error tuple. The recorded `exception` event carries `exception.type` only; messages, error reasons and stacktraces are never put on a span because they can contain event payloads, unique values or models. ## Attributes | Attribute | Notes | | --- | --- | | `cratis.event_store.name` | | | `cratis.event_store.namespace` | | | `cratis.event_sequence.id` | | | `cratis.event_type.id` | Append spans only — the event being appended | | `cratis.event.count` | `append_many` and unit-of-work commit spans | | `cratis.observer.id` | Reactor/reducer spans — the reactor or reducer’s id | | `cratis.correlation_id` | | | `cratis.event_source.id` | **Off by default** — see below | ### `cratis.event_source.id` is opt-in An event source id can itself be sensitive, so it is never set on a span unless explicitly enabled: ```elixir config :cratis_chronicle, trace_event_source_id: true ``` ## `:telemetry` events For applications that build metrics on `:telemetry` rather than an OpenTelemetry metrics exporter, the same operations also emit `:telemetry` spans — `:start`, `:stop`, and `:exception` events, per [`:telemetry.span/3`](https://hexdocs.pm/telemetry/telemetry.html#span-3): * `[:cratis, :chronicle, :client, :append, :start | :stop | :exception]` * `[:cratis, :chronicle, :client, :reactor, :handle, :start | :stop | :exception]` * `[:cratis, :chronicle, :client, :reducer, :reduce, :start | :stop | :exception]` ```elixir :telemetry.attach( "append-duration", [:cratis, :chronicle, :client, :append, :stop], fn _event, %{duration: duration}, metadata, _config -> Logger.info("Appended #{metadata.event_count} event(s) in #{System.convert_time_unit(duration, :native, :millisecond)}ms") end, nil ) ``` The `:stop` event’s metadata carries `error: true` when the operation returned `{:error, _}`, alongside the event count and sequence id. This is separate from the observer-lag telemetry described in [Observers](/chronicle/clients/elixir/observers/#lag) (`[:chronicle, :observer, :lag]`), which predates this convention and is not renamed by it. ## Trace propagation Every outbound gRPC call carries the W3C `traceparent`/`tracestate` headers for whichever OpenTelemetry span is active in the calling process, injected by `Chronicle.Connections.TracePropagationInterceptor`. This interceptor is always installed — like the rest of this instrumentation, it does nothing without an SDK-configured propagator, and is not something you configure or disable per connection. This means a trace that started in your application (an HTTP request, a Phoenix LiveView event) continues into the Chronicle kernel when your handler for it calls `Chronicle.EventSequences.EventLog.append/3` or a query, as long as the call happens inside the active span — for example inside `OpenTelemetry.Tracer.with_span/2`. ## Not covered * Stored kernel trace context: the kernel does not yet persist the append’s trace context with the event, so an observer’s delivery span is not yet linked back to the append that caused it (tracked upstream in Chronicle kernel issues). * OpenTelemetry metrics: only `:telemetry` events are emitted today; an OTel metrics instrument for the same measures is a future addition. # Observers > Chronicle.Observers provides an idiomatic Elixir API for working with Chronicle observers. `Chronicle.Observers` provides an idiomatic Elixir API for working with Chronicle observers. An observer is a reactor, reducer, or projection registered against an event sequence to be told about the events it cares about. This module is how an application finds out what the event store knows about its observers, and how it removes one whose declaring code is gone. Mirrors the C#, Kotlin, and TypeScript clients’ `IObservers`. ## Starting point Start `Chronicle.Client` first: ```elixir children = [ {Chronicle.Client, connection_string: "chronicle://localhost:35000", event_store: "banking", otp_app: :my_app} ] ``` ## Getting all observers Get every observer registered in the event store’s current namespace: ```elixir {:ok, observers} = Chronicle.Observers.get_all() ``` ## The `ObserverInformation` struct Each observer is returned as `%Chronicle.Observers.ObserverInformation{}` with: * `:id` * `:event_sequence_id` — the event sequence the observer observes * `:type` — `:reactor`, `:projection`, `:reducer`, `:external`, or `:unknown` * `:running_state` — `:active`, `:suspended`, `:replaying`, `:disconnected`, `:quarantined`, or `:unknown` * `:last_handled_event_sequence_number` * `:next_event_sequence_number` * `:handled_event_count` ```elixir Enum.each(observers, fn observer -> IO.puts("#{observer.id} (#{observer.type}) is #{observer.running_state}") end) ``` ## Removing an observer whose declaring code is gone Deleting a read model and its projection, or removing a reactor, does not remove what it registered. The observer stays behind, settles into `:disconnected`, and keeps its definition, state, handled counts, and failed partitions in the event store forever — until something removes them. ```elixir {:ok, result} = Chronicle.Observers.remove("MyApp.Reactors.OrderNotifier") if Chronicle.Observers.RemovalResult.removed?(result) do IO.puts("Removed.") else IO.puts("Refused: #{result.outcome} (#{result.blocking_namespace})") end ``` Removal covers the whole event store, not just the current namespace: an observer’s definition, and its projection definition where it has one, are store-level records shared by every namespace. `remove/2` refuses while the observer is running or still has a subscribed client in *any* namespace, and `blocking_namespace` names the one that blocked it, since the guard runs across all of them. **There is no override** — stop the declaring application first if the intent is to remove a live observer. Read model data and sink containers are left untouched; only the observer’s own bookkeeping goes. ## Using a named client ```elixir {:ok, observers} = Chronicle.Observers.get_all(client: :bank_chronicle) {:ok, result} = Chronicle.Observers.remove("MyApp.Reactors.OrderNotifier", client: :bank_chronicle) ``` ## Recovering a partition ```elixir {:ok, :started} = Chronicle.Observers.retry_partition(MyApp.Reactors.OrderNotifier, "order-1") {:ok, %{outcome: :cleared, retry_outcome: :started}} = Chronicle.Observers.clear_partition_quarantine(MyApp.Reactors.OrderNotifier, "order-1") :ok = Chronicle.Observers.clear_observer_quarantine(MyApp.Reactors.OrderNotifier) ``` The first argument is an observer id or a reactor, reducer or projection module. `retry_partition/3` returns `:started`, `:partition_not_found`, `:observer_quarantined` or `:partition_quarantined` (clear the quarantine first). `clear_partition_quarantine/3` retries immediately unless you pass `retry_immediately: false`. ## Lag An observer that was evicted or stalled is invisible from the append side. `Chronicle.Observers.Lag.measure/1` returns, per observer, the distance between the tail of the event sequence and the last event it handled. Start the reporter to publish it as telemetry: ```elixir {Chronicle.Client, observer_lag: [interval: 15_000], ...} :telemetry.attach("lag", [:chronicle, :observer, :lag], fn _, %{lag: lag}, %{observer_id: id}, _ -> if lag > 1_000, do: Logger.warning("#{id} is #{lag} events behind") end, nil) ``` # Behavior patterns > Ask what a scope usually does, using the behavior patterns Chronicle mined from the event history of an event store. `Chronicle.Patterns` provides an idiomatic Elixir API over the behavior patterns Chronicle mines from the event history of an event store — recurring combinations of facets (`Day`, `TimeBucket`, `CommandType`, `AggregateType`, and so on) that survived its support and confidence thresholds. An empty result from any of these functions is an answer, not a failure: it says the scope has no established behavior for the asked context. Nothing is invented to fill the gap, so a caller can treat “no patterns” as “do not claim to know”. ## Asking about a moment The question an application actually has is usually “what does this person usually do right now” — not an arbitrary facet combination. `get_patterns_at/3` is that question: ```elixir {:ok, patterns} = Chronicle.Patterns.get_patterns_at("user-42") ``` `Day` and `TimeBucket` are derived from the moment using its own offset, not UTC, with the same rule the kernel used when it mined the patterns — a scope’s established morning behavior is asked about in their own morning, wherever they are. The moment defaults to now; pass one explicitly to ask about a different time: ```elixir {:ok, patterns} = Chronicle.Patterns.get_patterns_at("user-42", tomorrow_morning) ``` Narrow the question with more of the situation using `:also_constraining` — asking about a moment and asking about a kind of work are the same call rather than two competing ones: ```elixir {:ok, patterns} = Chronicle.Patterns.get_patterns_at( "user-42", nil, also_constraining: %{Chronicle.Patterns.FacetName.aggregate_type() => "Invoice"} ) ``` A result names the action it answers for through `Chronicle.Patterns.FacetName.command_type()`: ```elixir action = pattern.facets[Chronicle.Patterns.FacetName.command_type()] ``` ## The low-level facet-set query `get_patterns_at/3` is a wrapper, not a replacement — `get_patterns/3` and `get_usual_actions/3` stay available for a context built by hand: ```elixir context = %{ Chronicle.Patterns.FacetName.day() => "Monday", Chronicle.Patterns.FacetName.time_bucket() => "Morning" } {:ok, patterns} = Chronicle.Patterns.get_patterns("user-42", context) {:ok, actions} = Chronicle.Patterns.get_usual_actions("user-42", context) ``` `get_patterns/3` returns patterns that describe a situation; `get_usual_actions/3` returns the ones that say what is done in it, at most one answer per action. ## Inspecting what has been mined ```elixir {:ok, patterns} = Chronicle.Patterns.get_patterns_for_scope("user-42") {:ok, scopes} = Chronicle.Patterns.get_scopes() ``` ## The time bucket rule `Chronicle.Patterns.time_bucket/1` exposes the same part-of-day rule the kernel mined with, so an application never writes its own copy of it — getting it wrong means asking about a slot the mining never used, which looks exactly like “nothing is established here”: | Hours | Bucket | | --- | --- | | 05:00–08:00 | `:early_morning` | | 08:00–11:00 | `:morning` | | 11:00–14:00 | `:midday` | | 14:00–17:00 | `:afternoon` | | 17:00–22:00 | `:evening` | | 22:00–05:00 | `:night` | ```elixir Chronicle.Patterns.time_bucket(DateTime.utc_now()) ``` ## Options Every function accepts: * `:client` — the client name (default: `Chronicle.Client`) * `:namespace` — overrides the client’s configured namespace * `:minimum_confidence` — the lowest confidence (`0.0`–`1.0`) an answer may hold; defaults to the server’s configured threshold * `:max_results` — the largest number of answers to return; defaults to the server’s default ## Not supported `AllPatterns` (every mined pattern for an event store, regardless of scope) is not exposed, matching the .NET client’s `IPatterns`. # Projections > Projections are shared Chronicle read-model behavior. Use the shared docs for projection styles, model-bound projections, declarative projections, and… Projections are shared Chronicle read-model behavior. Use the shared docs for projection styles, model-bound projections, declarative projections, and client-tabbed examples. * [Projections](/chronicle/projections/) * [Choosing a read model style](/chronicle/projections/choosing-a-read-model-style/) * [Model-bound projections](/chronicle/projections/model-bound/) * [Declarative projections](/chronicle/projections/declarative/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Child collections `children/2,3` declares a list on the parent that grows, changes and shrinks with events. Mirrors the .NET `[ChildrenFrom]`. It works in `use Chronicle.ReadModels.ReadModel` and `use Chronicle.Projections.Projection`: ```elixir defmodule MyApp.ReadModels.Basket do use Chronicle.ReadModels.ReadModel, types: [items: {:list, MyApp.ReadModels.BasketItem}] defstruct basket_id: "", items: [] from MyApp.Events.BasketCreated, set: [basket_id: :event_source_id] children :items, identified_by: :item_id do from MyApp.Events.ItemAdded, key: :item_id, parent_key: :event_source_id, set: [item_id: :item_id, name: :name, quantity: :quantity] from MyApp.Events.QuantityIncreased, key: :item_id, parent_key: :event_source_id, add: [quantity: :amount] removed_with MyApp.Events.ItemRemoved, key: :item_id, parent_key: :event_source_id end end ``` `identified_by` names the property that identifies a child. Inside the block `from/2`, `join/2`, `removed_with/2`, `removed_with_join/2`, `clear_with/2`, `from_every/1` and a nested `children/3` take the options they take on the read model. `key` identifies the child an event applies to and `parent_key` the parent it belongs to. Give nested children identifiers that are unique across read models In a nested `children/3`, `parent_key` names a child of the collection one level up, not the root. The kernel finds that child by its identifier across every instance of the read model. If two instances both have an entry `"e-1"`, a note for `"e-1"` can land in the wrong one. Use globally unique identifiers, such as UUIDs, for child collections that have children of their own. A collection that only ever grows from a single event does not need a `do` block: ```elixir children :entries, from: MyApp.Events.EntryAdded, identified_by: :entry_id, key: :entry_id, set: [entry_id: :entry_id, name: :name] ``` Unlike a root `join/2`, a join inside a `children` block does not require `:on` — it defaults to the collection’s `identified_by`, since a child is always joined by its own identifying property. An explicit `:on` that disagrees with `identified_by` is accepted but produces a compile-time warning rather than an error. `removed_with_join/2` removes a child when a secondary event — from a different event source — matches it by a property instead of by the collection’s own key: ```elixir removed_with_join MyApp.Events.EntryArchivedElsewhere, key: :archived_entry_id ``` Use `clear: [:field]` in a mapping, or the `clear_with event, [:field]` sugar, to set a scalar back to no value. The target field must default to `nil`: a field whose default is a concrete value (such as `""`) is not nullable to the kernel, and clearing it writes the type default back instead of no value. A few mistakes are caught at compile time rather than at registration: a `children` collection without `:identified_by`, a root `join/2` without `:on`, the same target property assigned twice by one event’s mapping, and a `from`/`join` target or `children` name that is not a field of the read model’s struct. ## Pinning the id `use Chronicle.Projections.Projection` defaults a declarative projection’s id to the last segment of its module name. Renaming or moving the module then changes that id, and Chronicle treats it as a *different* projection — its observation position resets. Pin the id explicitly so the module can be renamed or moved freely: ```elixir use Chronicle.Projections.Projection, id: "account-summary", model: MyApp.ReadModels.AccountSummary ``` A projection relying on the module-name default gets a one-time warning (per module, for the life of the BEAM node) from [discovery](/chronicle/clients/elixir/discovery/), naming the module and showing this fix. See [Discovery and artifact identity drift](/chronicle/clients/elixir/discovery/). ## Ad-hoc querying with `Chronicle.Projections` `Chronicle.Projections.query/2` sends a Projection Declaration Language (PDL) string straight to the kernel and returns the projected entries, without registering a permanent projection or defining a read model module: ```elixir {:ok, result} = Chronicle.Projections.query(""" projection OrderSummary from OrderPlaced """) result.instances # decoded as plain maps unless :read_model is given ``` Pass `:read_model` to decode each entry as a struct instead. A declaration with syntax or type errors returns `{:error, {:invalid_declaration, [%{message:, line:, column:}]}}`. See the shared [Ad-hoc Querying](/chronicle/projections/projection-declaration-language/adhoc-querying/) page for the full picture — Elixir now joins C# in supporting this. `Chronicle.Projections.preview/2` runs an already-registered projection’s own declaration the same way, looked up by read model module or projection identifier. ## Waiting for a projection `Chronicle.Projections.wait_for/3` polls until a projection has handled a given event sequence number, or returns `{:error, :timeout}`: ```elixir {:ok, %{sequence_number: sequence}} = Chronicle.EventSequences.EventLog.append_with_result(account_id, event) :ok = Chronicle.Projections.wait_for(MyApp.ReadModels.Account, sequence) ``` ## Event context values `:occurred` maps to the event’s occurred time (`$eventContext(Occurred)`). The old `$occurred` spelling was rejected by the kernel and is now translated. # Reactors > Reactors are documented in the shared Chronicle docs with synchronized examples for C#, Kotlin, Java, Elixir, and TypeScript. Reactors are documented in the shared Chronicle docs with synchronized examples for C#, Kotlin, Java, Elixir, and TypeScript. * [Getting started with reactors](/chronicle/reactors/getting-started/) * [Reactor event processing](/chronicle/reactors/event-processing/) * [Reactors overview](/chronicle/reactors/) Use the [Elixir get started page](/chronicle/clients/elixir/get-started/) for client startup and supervision setup. ## Side-effect appending `handle/2` can return `{:ok, event_or_events}` in addition to `:ok`/`{:error, reason}`, and Chronicle.Elixir appends the returned event(s) as a side effect right after `handle/2` returns. Mirrors C#‘s `Reactors/SideEffects` and Kotlin’s `ReactorsService.appendSideEffects`. Returning a single event struct appends it to the same event source that triggered the handler: ```elixir defmodule MyApp.Reactors.OrderNotifier do use Chronicle.Reactors.Reactor alias MyApp.Events.{OrderPlaced, OrderConfirmationQueued} @handles OrderPlaced @impl true def handle(%OrderPlaced{} = event, _context) do {:ok, %OrderConfirmationQueued{customer_id: event.customer_id}} end end ``` Return a list of events to append them all atomically to that same event source. To target a **different** event source, wrap the event in a `Chronicle.EventSequences.EventForEventSourceId`; a list of these (optionally mixed with bare event structs, which are normalized to the triggering event source) is appended atomically across all their event source ids in one call. If appending the side effect fails, `handle/2`’s overall result becomes `{:error, reason}` — reported the same way as a plain `{:error, reason}` return. ## Replay lifecycle hooks Implement any of `on_replay_begin/0`, `on_replay_end/0`, `on_partition_replay_begin/1`, or `on_partition_replay_end/1` to be notified when Chronicle starts or finishes replaying this reactor — either as a whole, or for a single partition (event source). All four are optional. Mirrors C#‘s `ICanBeNotifiedWhenReplay` / `ICanBeNotifiedWhenPartitionReplayed`. ```elixir defmodule MyApp.Reactors.ReplayAwareNotifier do use Chronicle.Reactors.Reactor require Logger alias MyApp.Events.OrderPlaced @handles OrderPlaced @impl true def handle(%OrderPlaced{}, _context), do: :ok @impl true def on_replay_begin, do: Logger.info("Replay starting") @impl true def on_replay_end, do: Logger.info("Replay finished") end ``` These are notifications, not events to handle — they run outside normal `handle/2` dispatch, are not subject to failed-partition tracking, and a raised exception is logged and swallowed rather than reported to Chronicle. ## Replay: once-only and replay handlers Reactors are replayable by default: when the kernel replays an observer, `handle/2` runs again for past events. That is wrong for side effects such as sending an email. Mirrors .NET’s `[OnceOnly]` and `[Replay]`. ```elixir # Never replayed: registered with the kernel as not replayable. use Chronicle.Reactors.Reactor, once_only: true # Replayable, but these events are skipped while replaying. use Chronicle.Reactors.Reactor, once_only: [MyApp.Events.OrderShipped] ``` While a replay is running `context.replaying?` is `true`. Define the optional `handle_replay/2` to handle replayed events differently - when present it is called *instead of* `handle/2`: ```elixir @impl true def handle(%OrderPlaced{} = event, _context), do: send_confirmation(event) @impl true def handle_replay(%OrderPlaced{} = event, _context), do: update_projection_cache(event) ``` Excluding replays is not exactly-once delivery: recovering a failed partition can deliver an event again as an ordinary observation. Guard side effects with the delivery id below. ## Starting at the tail A new reactor catches up from the very beginning of the event sequence, as ordinary observation, so a reactor whose handlers cause side effects acts on every historical event the moment it is deployed. `once_only` does not prevent that, because it only excludes replay. Set `start_at_tail: true` and the first time Chronicle sees the reactor it begins at the tail of the event sequence and observes only what is appended afterwards: ```elixir use Chronicle.Reactors.Reactor, start_at_tail: true ``` The option only affects the first registration: a reactor that already has a position keeps it, so it never skips events after a restart. Changing the module name without pinning `:id` makes a new reactor, which starts at the tail again. An explicit replay still replays from the beginning. Both the Chronicle server and this client must support it; a server that predates it ignores the request and the reactor catches up from the beginning. ## Pinned generation delivery By default (`:compatibility`), Chronicle upgrades every event to the handled module’s own generation before delivery, so `handle/2` always sees that generation’s shape regardless of which generation was actually appended. That is the right default when the handler is written against the current generation. Set `generation_delivery: :pinned` when the handler is written against an **older** generation and must see events exactly as that generation was appended, instead of upgraded to the current one: ```elixir use Chronicle.Reactors.Reactor, generation_delivery: :pinned ``` `context.appended_generation` then tells you which generation was actually appended (`0` means unknown — an older kernel, or an event appended outside a registered event source). A client-wide default applies to every reactor that doesn’t set its own `:generation_delivery`: ```elixir {Chronicle.Client, ... event_generation_delivery: :pinned} ``` A reactor’s own option always wins over the client default. With `:pinned`, a reactor handling two generations of the same event type id has no single generation to pin to and raises `Chronicle.Observers.MultipleGenerationsOfEventTypeInObserver` on start. ## Pinning the id `use Chronicle.Reactors.Reactor` defaults the reactor’s id to its full module name. Renaming or moving the module then changes that id, and Chronicle treats it as a *different* reactor — its observation position resets, replaying events the previous name had already handled. Pin the id explicitly so the module can be renamed or moved freely: ```elixir use Chronicle.Reactors.Reactor, id: "order-notifier" ``` A reactor relying on the module-name default gets a one-time warning (per module, for the life of the BEAM node) from [discovery](/chronicle/clients/elixir/discovery/), naming the module and showing this fix. Pass `warn_defaulted_ids: false` to `Chronicle.Client` to suppress it. See [Discovery and artifact identity drift](/chronicle/clients/elixir/discovery/). ## Delivery identity Every reactor invocation context carries `delivery_id`: a stable identity of *this delivery of this event to this reactor*, the same value each time the event is redelivered. It is derived like the .NET `ReactorDelivery.Id`: the reactor id, event store, namespace, event sequence id, event source id and sequence number joined with `#`. Use it as the key of whatever records that the side effect happened: ```elixir def handle(%OrderPlaced{} = event, %{delivery_id: delivery_id}) do case Receipts.insert_once(delivery_id) do :inserted -> Mailer.send_confirmation(event) :already_there -> :ok end end ``` The id identifies the delivery; it does not make the external effect atomic with the receipt. ## Middleware `Chronicle.Middleware` wraps the handling of an event in a reactor or reducer, for tracing, metrics, logging or impersonating the identity that caused the event: ```elixir defmodule MyApp.Middleware.Timing do @behaviour Chronicle.Middleware @impl true def call(event, context, next) do {micros, result} = :timer.tc(fn -> next.(event, context) end) Logger.info("handled in #{micros}us") result end end config :cratis_chronicle, middleware: [MyApp.Middleware.Timing] # every reactor and reducer use Chronicle.Reactors.Reactor, middleware: [MyApp.Middleware.Impersonate] # one module ``` Global middleware is the outer layer, then the module’s own, each in the order listed. # Read Models > Read models are shared Chronicle concepts. Querying, snapshots, watching, and consistency are documented in the shared Chronicle section. Read models are shared Chronicle concepts. Querying, snapshots, watching, and consistency are documented in the shared Chronicle section. * [Read models](/chronicle/read-models/) * [Getting a single read model](/chronicle/read-models/getting-single-instance/) * [Getting read model collections](/chronicle/read-models/getting-collection-instances/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Missing and not-yet-projected read models `Chronicle.read_model/3`, and `Chronicle.ReadModels.get/3` which it calls, return `{:ok, nil}` when no instance exists for the key. That is also what you get right after an append whose projection hasn’t run yet, because Chronicle projects asynchronously: ```elixir case Chronicle.read_model(MyApp.ReadModels.AccountInfo, account_id) do {:ok, nil} -> :not_found_or_not_projected_yet {:ok, account} -> {:ok, account} {:error, reason} -> {:error, reason} end ``` When you need to read your own write, append with `Chronicle.EventSequences.EventLog.append_and_wait_for_completion/3`, which returns once the affected observers have processed the event. See [Event sequences](/chronicle/clients/elixir/event-sequences/#appending-and-waiting-for-observer-completion). ## Watching for live changes `Chronicle.ReadModels.watch/2` subscribes the calling process to live changesets for a read model over the kernel’s server-streaming `Watch` RPC. Elixir has no `IObservable`/ async-iterable equivalent to the C# and TypeScript clients’ `Watch()`, so this follows the same message-based idiom as `Chronicle.Connections.Lifecycle.subscribe/1`. ```elixir {:ok, watcher} = Chronicle.ReadModels.watch(MyApp.ReadModels.AccountInfo) receive do {:chronicle_read_model_changed, MyApp.ReadModels.AccountInfo, changeset} -> # changeset is a %Chronicle.ReadModels.Changeset{} with :model_key, :read_model, # :change_type (:added | :modified | :removed), :event_sequence_number, and more. IO.inspect(changeset.read_model) {:chronicle_read_model_watch_error, MyApp.ReadModels.AccountInfo, reason} -> # The stream failed and the watch has ended — call watch/2 again to resume. IO.inspect(reason) end Chronicle.ReadModels.unwatch(watcher) ``` ## Shared subscriptions `Chronicle.ReadModels.subscribe/2` is a shared alternative to `watch/2`: every caller that subscribes to the same read model (same client, namespace, and event sequence) is served by one kernel stream instead of each caller opening its own. Read model reactors use it, so a reactor and an ad-hoc subscriber for the same read model share a stream automatically. ```elixir {:ok, watcher} = Chronicle.ReadModels.subscribe(MyApp.ReadModels.AccountInfo) receive do {:chronicle_read_model_changed, MyApp.ReadModels.AccountInfo, changeset} -> IO.inspect(changeset.read_model) end Chronicle.ReadModels.unsubscribe(watcher) ``` On top of `watch/2`’s messages, a subscriber also gets: * `{:chronicle_read_model_watch_ended, model_module, reason}` — the shared stream failed. Every subscriber gets this once; the watcher reconnects on its own with exponential backoff (500 ms, doubling up to 30 s). * `{:chronicle_read_model_watch_resumed, model_module}` — the stream reconnected. **Changes made while the stream is down are not replayed.** The shared watcher closes its stream once its last subscriber unsubscribes, or exits. ## Dehydrating a session A read-model session (created implicitly whenever you read with a `:session_id`) normally expires on its own. `dehydrate_session/4` explicitly cleans one up once the caller is done with it, releasing the resources Chronicle held for it right away. ```elixir :ok = Chronicle.ReadModels.dehydrate_session( MyApp.ReadModels.AccountInfo, account_id, session_id ) ``` ## Filtering and sorting `Chronicle.ReadModels.query/2` accepts `:filter` and `:sort`. All conditions must match; a condition is a value (equality) or one of `{:gt, v}`, `{:gte, v}`, `{:lt, v}`, `{:lte, v}`, `{:ne, v}`, `{:in, [v]}`, `{:contains, v}`: ```elixir {:ok, result} = Chronicle.ReadModels.query(MyApp.ReadModels.Entry, filter: [schema_id: "invoice", amount: {:gt, 100}], sort: {:amount, :desc}, page_size: 20 ) result.total_count # the number of *matching* instances ``` An unknown field returns `{:error, {:unknown_field, field}}`. **The filter is applied by the client.** The kernel’s query contract pages instances but cannot filter them, so the client pages through the read model and evaluates the filter itself. That is right for read models of modest size. A read model with more than `:max_scan` instances (default `10_000`) returns `{:error, {:scan_limit_exceeded, max_scan}}` rather than a silently partial answer. For large data, give the question its own projection. ## Observing a collection `Chronicle.ReadModels.observe/2` sends the calling process the page of instances now and again each time it changes: ```elixir {:ok, observer} = Chronicle.ReadModels.observe(MyApp.ReadModels.AccountInfo, page_size: 25) receive do {:chronicle_read_model_instances, MyApp.ReadModels.AccountInfo, %{instances: accounts}} -> accounts end Chronicle.ReadModels.unobserve(observer) ``` An error arrives as `{:chronicle_read_model_observe_error, model, reason}` and ends the observation; call `observe/2` again to resume. `watch/2` remains the tool for the changes of one instance. Pass `:filter`/`:sort` to keep and order only matching instances in each delivered page — same shape as [`query/2`’s filter and sort](#filtering-and-sorting). `total_count` then counts the matches within that page, not the whole read model. `observe_stream/2` wraps `observe/2` as an Elixir `Stream` of `%Chronicle.ReadModels.QueryResult{}`: ```elixir MyApp.ReadModels.AccountInfo |> Chronicle.ReadModels.observe_stream(page_size: 25) |> Enum.each(fn result -> IO.inspect(result.instances) end) ``` ## Read model reactors A `Chronicle.ReadModels.Reactor` reacts to *changes of a read model* rather than to events: ```elixir defmodule MyApp.Reactors.BalanceWatcher do use Chronicle.ReadModels.Reactor, model: MyApp.ReadModels.AccountInfo @impl true def on_modified(account, _changeset) do if account.balance < 0, do: Alerts.overdrawn(account) :ok end end {Chronicle.Client, read_model_reactors: [MyApp.Reactors.BalanceWatcher], ...} ``` `on_added/2`, `on_modified/2` and `on_removed/2` are all optional. The reactor subscribes through `Chronicle.ReadModels.subscribe/2`, so it shares its kernel stream with any other subscriber of the same read model, receives changes that happen while it runs, and re-establishes its subscription after a reconnect. ## Decision reads and the Dynamic Consistency Boundary `Chronicle.ReadModels.DecisionReads` folds a read model at a key and carries forward the exact log position the fold stopped at, so a subsequent append can be guarded against anything else changing that read model first. See [Dynamic Consistency Boundary](/chronicle/clients/elixir/dynamic-consistency-boundary/). ## Map fields and nested structs A field whose default is a plain map (`parameters: %{}`) is registered as an object, its keys kept exactly as you wrote them. Nested structs keep their multi-word field names. Fields that default to `nil`, or hold a list of structs, are declared with `types:`: ```elixir use Chronicle.ReadModels.ReadModel, types: [lines: {:list, MyApp.OrderLine}, address: MyApp.Address, labels: :map] ``` See `Chronicle.Serialization`. # Reducers > Reducers are shared Chronicle read-model behavior. Use the shared reducer docs for the model, lifecycle, filtering, and client-tabbed examples. Reducers are shared Chronicle read-model behavior. Use the shared reducer docs for the model, lifecycle, filtering, and client-tabbed examples. * [Reducers](/chronicle/reducers/) * [Getting started with reducers](/chronicle/reducers/getting-started/) * [Reducer event processing](/chronicle/reducers/event-processing/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Passive reducers `use Chronicle.Reducers.Reducer` accepts an `:active` option, defaulting to `true`. Set it to `false` for a **passive** reducer: its read model is only computed on demand (e.g. via `Chronicle.ReadModels.get_instance_by_id/3`) instead of being kept warm in the background as events are appended — useful for a reducer whose read model backs a command-side decision that’s read rarely, where continuous observation would be wasted work. Mirrors C#‘s `ReducerAttribute.IsActive`. ```elixir defmodule MyApp.Reducers.AccountReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.AccountInfo, active: false alias MyApp.Events.OrderPlaced @handles OrderPlaced @impl true def reduce(%OrderPlaced{}, model, _context), do: model end ``` ## Pinning the id `use Chronicle.Reducers.Reducer` defaults the reducer’s id to its full module name. Renaming or moving the module then changes that id, and Chronicle treats it as a *different* reducer — its read model observation position resets. Pin the id explicitly so the module can be renamed or moved freely: ```elixir use Chronicle.Reducers.Reducer, id: "account-reducer", model: MyApp.ReadModels.AccountInfo ``` A reducer relying on the module-name default gets a one-time warning (per module, for the life of the BEAM node) from [discovery](/chronicle/clients/elixir/discovery/), naming the module and showing this fix. See [Discovery and artifact identity drift](/chronicle/clients/elixir/discovery/). ## Replay lifecycle hooks Reducers support the same optional `on_replay_begin/0`, `on_replay_end/0`, `on_partition_replay_begin/1`, and `on_partition_replay_end/1` callbacks as reactors — see [Replay lifecycle hooks](/chronicle/clients/elixir/reactors/#replay-lifecycle-hooks) for details. They run outside normal `reduce/3` dispatch and are not events to reduce. ## Pinned generation delivery By default (`:compatibility`), Chronicle upgrades every event to the handled module’s own generation before delivery, so `reduce/3` always sees that generation’s shape regardless of which generation was actually appended. That is the right default when the reducer is written against the current generation. Set `generation_delivery: :pinned` when the reducer is written against an **older** generation and must see events exactly as that generation was appended, instead of upgraded to the current one: ```elixir use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.AccountInfo, generation_delivery: :pinned ``` `context.appended_generation` then tells you which generation was actually appended (`0` means unknown — an older kernel, or an event appended outside a registered event source). A client-wide default applies to every reducer that doesn’t set its own `:generation_delivery`: ```elixir {Chronicle.Client, ... event_generation_delivery: :pinned} ``` A reducer’s own option always wins over the client default. With `:pinned`, a reducer handling two generations of the same event type id has no single generation to pin to and raises `Chronicle.Observers.MultipleGenerationsOfEventTypeInObserver` on start. # Seeding > Define Elixir seeders that append baseline events when the client registers, and understand when seed data is skipped. Seeding gives a fresh event store the events it needs to be useful, such as reference data or demo accounts, without a separate script. This page shows how to seed events with the Chronicle Elixir client. See [Event seeding](/chronicle/event-seeding/) for the concept this page assumes, or [Plan seed data](/chronicle/clients/elixir/seeding/overview/) to decide what belongs in your baseline. Every time the client finishes registering with the kernel it runs your seeders and hands their entries to the kernel’s seeding service, exactly as the .NET client does. The kernel tracks which entries it has applied, so running again is safe and only new entries are applied; see [How it runs](#how-it-runs). ## Define events ```elixir defmodule MyApp.Events.SeededAccountOpened do use Chronicle.Events.EventType, id: "seeding-account-opened" defstruct account_id: "", owner_name: "", initial_balance: 0 end defmodule MyApp.Events.SeededFundsDeposited do use Chronicle.Events.EventType, id: "seeding-funds-deposited" defstruct account_id: "", amount: 0 end ``` Give every field a typed default: the client builds the event’s JSON schema from the defaults, and a `nil` default registers the field as a string. ## Implement a seeder Use `Chronicle.Seeding.Seeder` and implement the `seed/1` callback: ```elixir defmodule MyApp.Seeders.AccountSeeder do use Chronicle.Seeding.Seeder alias MyApp.Events.SeededAccountOpened @impl true def seed(builder) do builder |> Chronicle.Seeding.for(SeededAccountOpened, "account-1", [ %SeededAccountOpened{account_id: "account-1", owner_name: "Alice", initial_balance: 1000} ]) end end ``` `seed/1` returns the builder. Register the seeder explicitly, or let discovery find it: ```elixir # Explicit {Chronicle.Client, connection_string: connection_string, event_store: "banking", seeders: [MyApp.Seeders.AccountSeeder]} # Discovery: finds every module in :my_app that uses Chronicle.Seeding.Seeder {Chronicle.Client, connection_string: connection_string, event_store: "banking", otp_app: :my_app} ``` ## Seed multiple events of the same type `Chronicle.Seeding.for/4` seeds one or more events of the same type for a single event source: ```elixir def seed(builder) do builder |> Chronicle.Seeding.for(SeededAccountOpened, "account-1", [ %SeededAccountOpened{account_id: "account-1", owner_name: "Alice", initial_balance: 1000} ]) |> Chronicle.Seeding.for(SeededAccountOpened, "account-2", [ %SeededAccountOpened{account_id: "account-2", owner_name: "Bob", initial_balance: 500} ]) end ``` ## Seed mixed event types `Chronicle.Seeding.for_event_source/3` seeds several different event types for the same event source: ```elixir def seed(builder) do builder |> Chronicle.Seeding.for_event_source("account-1", [ %SeededAccountOpened{account_id: "account-1", owner_name: "Alice", initial_balance: 1000}, %MyApp.Events.SeededFundsDeposited{account_id: "account-1", amount: 500} ]) end ``` ## Namespace-scoped seed data Seed data without a namespace goes to the namespace the client is configured with, the `:namespace` option on `Chronicle.Client`, which defaults to `"Default"`. It isn’t copied to other namespaces. To target a specific namespace, use `Chronicle.Seeding.for_namespace/3`: ```elixir def seed(builder) do builder |> Chronicle.Seeding.for_namespace("tenant-a", fn scoped -> scoped |> Chronicle.Seeding.for(SeededAccountOpened, "account-1", [ %SeededAccountOpened{account_id: "account-1", owner_name: "Alice", initial_balance: 1000} ]) end) end ``` The scoped builder supports the same `for/4` and `for_event_source/3` functions as the top-level builder. ## How it runs * Seeders run each time the client reaches the `:registered` phase: at startup, and again after every reconnect. See [Resilience](/chronicle/clients/elixir/connections/resilience/). * Every seeder’s `seed/1` runs first, accumulating entries. A seeder that raises is logged as an error and contributes **none** of its entries (a seeder is applied whole or not at all); the other seeders still run. * The client groups the entries by namespace, event type and event source and sends them in one request to the kernel’s seeding service. Event type tags (`use Chronicle.Events.EventType, tags: [...]`) travel with each entry. * The kernel remembers which entries it has applied, by event source, event type, content and tags. **An entry you add later to an event source that was seeded before is applied; an entry that was applied is not applied again.** Entries without `for_namespace/3` are global: the kernel applies them to every namespace it knows about. * If the request fails, for example because the kernel rejects an event, seeding logs `Seeding failed` and runs again from the start five seconds later. Because the kernel does the bookkeeping, it is safe for several application instances to start together. Editing the *content* of an entry that was already applied is a new entry: the old event stays. To correct stored seed data, append correcting events yourself. ## Best practices * Keep seed data minimal and deterministic, with fixed event source ids and content, so reruns apply nothing. * You can add entries to an already seeded event source later; only the new entries are applied. * Use typed defaults on seeded event types, just like any other event. A schema violation fails the request and seeding retries every five seconds. * Use the `:id` option on `use Chronicle.Seeding.Seeder` only if you need a stable identifier for tooling. The client doesn’t send it to Chronicle. * Use `for_namespace/3` when seed data is tenant-specific or environment-specific. # Plan seed data > Decide which baseline events your Elixir application needs before implementing a Chronicle seeder. A fresh event store may need reference data before the application can handle its first request. Decide which facts belong in that baseline before writing a seeder. Keep user-driven changes in the application’s normal append flow rather than treating them as startup data. ## Before implementing a seeder * Identify the event types and event source ids the application needs at startup. Keep the list small enough to review. * Use fixed ids and deterministic event content rather than generating different data each time the client starts. * Separate data shared by tenants from data that belongs to a specific namespace. Make that choice explicit in the seeder. * Plan corrections as new events. Seeding is not a migration script for rewriting events already stored. The shared [Event seeding](/chronicle/event-seeding/) page explains the Chronicle behavior. Continue with [Implement seeding in Elixir](/chronicle/clients/elixir/seeding/) for event definitions, seeder registration, namespace-scoped data, and retry behavior. If you have not connected a client yet, start with [Elixir client setup](/chronicle/clients/elixir/get-started/). # Sinks > Choose where the Elixir client's projections and reducers store read models, and run clients with different sinks. See [Sinks](/chronicle/sinks/) for what a sink is and the storage backends Chronicle ships with. This page covers Elixir-specific configuration: setting the default sink per client, running multiple clients with different sinks, and the `Chronicle.Sinks.WellKnownSinkTypes` reference. ## Default sink Unless configured otherwise, Chronicle writes read models into MongoDB. You do not need to set anything — the default is correct for most applications. ## Changing the default sink Pass `:default_sink_type_id` when starting `Chronicle.Client`: ```elixir {Chronicle.Client, connection_string: "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000", event_store: "my-store", default_sink_type_id: :sql, read_models: [MyApp.ReadModels.Account], reducers: [MyApp.Reducers.AccountReducer]} ``` The option accepts an atom from `Chronicle.Sinks.WellKnownSinkTypes` or a raw string identifier for custom sink types. The kernel must be configured with the sink you choose; `:sql` needs a kernel with SQL read model storage, as described in the shared [Sinks](/chronicle/sinks/) page. | Atom | String | Storage backend | | --- | --- | --- | | `:mongodb` | `"MongoDB"` | MongoDB (default) | | `:sql` | `"SQL"` | SQL database | | `:in_memory` | `"InMemory"` | In-process memory only | | `:none` | `"None"` | No sink: the read model is not persisted | The configured sink applies to the projections and reducers that client registers, with one exception: a read model declared with `passive: true` is registered with the `None` sink, because it is computed on demand rather than stored. There is no other per-read-model sink override. An unknown atom raises `ArgumentError` when the client starts. ## Multiple clients with different sinks If your application needs some read models in MongoDB and others in SQL, start two separate `Chronicle.Client` instances with different names and assign the relevant read models to each. `connection_string` here stands for your connection string: ```elixir children = [ Supervisor.child_spec( {Chronicle.Client, name: :mongo_client, connection_string: connection_string, event_store: "store", discover: false, default_sink_type_id: :mongodb, read_models: [MyApp.ReadModels.AccountSummary]}, id: :mongo_client ), Supervisor.child_spec( {Chronicle.Client, name: :sql_client, connection_string: connection_string, event_store: "store", discover: false, default_sink_type_id: :sql, read_models: [MyApp.ReadModels.AccountReport]}, id: :sql_client ) ] Supervisor.start_link(children, strategy: :one_for_one) ``` Each client needs its own child id through `Supervisor.child_spec/2`: every `Chronicle.Client` child spec defaults to the id `Chronicle.Client`, and a supervisor refuses two children with the same id. `discover: false` keeps each client from also discovering, and registering, the other client’s read models; list each client’s event types, reducers and other artifacts explicitly instead. Pass `:client` when querying to target the right instance: ```elixir {:ok, summary} = Chronicle.ReadModels.get(MyApp.ReadModels.AccountSummary, id, client: :mongo_client) {:ok, report} = Chronicle.ReadModels.get(MyApp.ReadModels.AccountReport, id, client: :sql_client) ``` ## Reference — `Chronicle.Sinks.WellKnownSinkTypes` | Function | Returns | | --- | --- | | `WellKnownSinkTypes.mongodb/0` | `"MongoDB"` | | `WellKnownSinkTypes.sql/0` | `"SQL"` | | `WellKnownSinkTypes.in_memory/0` | `"InMemory"` | | `WellKnownSinkTypes.none/0` | `"None"` | | `WellKnownSinkTypes.resolve/1` | Resolves an atom or passthrough string | `resolve/1` is used internally by `Chronicle.Client` to normalize the `:default_sink_type_id` option. You rarely need to call it directly, but it is useful when building custom tooling or test helpers: ```elixir alias Chronicle.Sinks.WellKnownSinkTypes WellKnownSinkTypes.resolve(:mongodb) # => "MongoDB" WellKnownSinkTypes.resolve(:sql) # => "SQL" WellKnownSinkTypes.resolve("Custom") # => "Custom" ``` # Testing > Test commands, reactors, reducers, constraints and concurrency against an in-memory kernel, and assert on what was appended. ## Start a client against an in-memory kernel `Chronicle.Testing.start_supervised_client/1` boots a real `Chronicle.Client` pointed at `Chronicle.Testing.InMemoryKernel` instead of a socket. Appends, constraints, concurrency scopes and unit-of-work all run through the real client code — only the transport is fake. ```elixir {:ok, client} = Chronicle.Testing.start_supervised_client( event_types: [MyApp.Events.AccountOpened, MyApp.Events.FundsDeposited], reducers: [MyApp.Reducers.AccountReducer], reactors: [MyApp.Reactors.WelcomeReactor] ) :ok = Chronicle.EventSequences.EventLog.append( "account-1", %MyApp.Events.AccountOpened{owner_name: "Alice", initial_balance: 100}, client: client ) Chronicle.Testing.stop_supervised_client(client) ``` Prefer `Chronicle.Testing.Case` in ExUnit, which does this setup and teardown for you and is safe under `async: true`: ```elixir defmodule MyApp.AccountTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.AccountOpened], reducers: [MyApp.Reducers.AccountReducer] import Chronicle.Testing.Assertions test "opening an account appends AccountOpened", %{chronicle: client} do :ok = Chronicle.EventSequences.EventLog.append( "account-1", %MyApp.Events.AccountOpened{owner_name: "Alice", initial_balance: 100}, client: client ) assert_appended(client, MyApp.Events.AccountOpened, event_source_id: "account-1") end end ``` ## What runs for real, and what is modeled Registration, appends and reads go through the real client code against `Chronicle.Testing.InMemoryKernel`, which answers the compatibility check, `Append`/`AppendManyForEventSources`, reads, `TailSequenceNumber`, `HasEventsForEventSourceId`, and the registration calls. Concurrency scopes are honored along every dimension, including `expecting_no_matching_event/1`. Constraints support `unique/2` (scoped, case-insensitive, respecting removal) and `unique_event_type/1`. Every other RPC — most prominently `closes_stream` constraints, and the real `ReadModels`/`Projections` query surface — returns `{:error, {:not_supported_in_memory, rpc}}`. Reactors and reducers do not run through a real gRPC observation stream (the in-memory kernel does not simulate bidirectional streaming): a `Chronicle.Testing.Dispatcher` process runs them synchronously, right after each committed append, by calling the same dispatch logic the real stream handlers use. A reactor’s own side-effect append runs through the real client again, in this dispatcher process — never inside the kernel’s own process, so it can never deadlock it. ## Waiting for reactors and reducers An append returns as soon as the event is committed; reactors and reducers run afterward. `Chronicle.Testing.wait_for_observers/1` is a deterministic, sleep-free barrier — it blocks until every batch appended so far has been processed: ```elixir :ok = Chronicle.EventSequences.EventLog.append("account-1", %FundsDeposited{amount: 25}, client: client) :ok = Chronicle.Testing.wait_for_observers(client) {:ok, %AccountReadModel{balance: 125}} = Chronicle.Testing.read_model(client, MyApp.Reducers.AccountReducer, "account-1") ``` It returns `{:error, failures}` when a reactor or reducer raised while processing an event. ## Reading reducer state and raw events `Chronicle.Testing.read_model/3` reads the read model state a reducer built, keyed by event source id — there is no in-memory `ReadModels` query surface to read it through. `Chronicle.Testing.appended_events/2` returns every event appended for an event source, decoded into `Chronicle.Events.AppendedEvent` structs. ## Assertions `Chronicle.Testing.Assertions` asserts on appended events and on the structured rejections `Chronicle.EventSequences.EventLog` returns: ```elixir import Chronicle.Testing.Assertions assert_appended(client, %AccountOpened{owner_name: "Alice", initial_balance: 100}, event_source_id: "account-1" ) assert_appended(client, FundsDeposited, event_source_id: "account-1", count: 1) refute_appended(client, AccountClosed, event_source_id: "account-1") result = Chronicle.EventSequences.EventLog.append("user-2", %UserRegistered{email: "taken@example.com"}, client: client) assert_constraint_violation(result, "UniqueEmail") ``` A matcher is an exact event struct (compared with `==`), an event type module (any event of that type), or a 1-arity predicate function. ## Evaluating a projection read model in memory `Chronicle.Testing.Projections` evaluates the supported subset of a `Chronicle.ReadModels.ReadModel`’s embedded declarations — `from/2` (`:set`, `:add`, `:subtract`, `:count`, `:increment`, `:decrement`, `:clear`, auto-map) and `removed_with/2` — against a single event, without a kernel: ```elixir {:ok, account} = Chronicle.Testing.Projections.apply_event(AccountReadModel, nil, %AccountOpened{...}) {:ok, account} = Chronicle.Testing.Projections.apply_event(AccountReadModel, account, %FundsDeposited{amount: 25}) ``` `join/2`, `children/2`, `from_every/1` and `removed_with_join/2` need the server-side projection engine and raise `Chronicle.Testing.UnsupportedProjectionOperation` — use `Chronicle.Testing.Integration` against a real kernel for those. ## Mocking the `Chronicle` facade `Chronicle` adopts `Chronicle.Behaviour`, so code that only needs to be observed calling the facade with the right arguments can mock it with [Mox](https://hex.pm/packages/mox) (not a dependency of this library): ```elixir Mox.defmock(MyApp.ChronicleMock, for: Chronicle.Behaviour) Mox.expect(MyApp.ChronicleMock, :append, fn "account-1", %AccountOpened{}, _opts -> :ok end) ``` Prefer `Chronicle.Testing.start_supervised_client/1` when the behavior under test should see real appends, reactors, reducers, constraints and concurrency checks. ## Integration tests against a real kernel `Chronicle.Testing.Integration.setup_isolated_client/2` starts a real `Chronicle.Client` against a running kernel (read from `CHRONICLE_INTEGRATION_CONNECTION_STRING` by default), with a unique event store and namespace so the test never collides with another test’s events: ```elixir @moduletag :integration setup do Chronicle.Testing.Integration.setup_isolated_client( event_types: [MyApp.Events.AccountOpened], reducers: [MyApp.Reducers.AccountReducer] ) end test "appends and projects", %{chronicle: client} do {:ok, result} = Chronicle.EventSequences.EventLog.append_with_result("account-1", %AccountOpened{}, client: client) :ok = Chronicle.Testing.Integration.wait_for_observers(result.sequence_number, client: client) end ``` `wait_for_observers/2` delegates to `Chronicle.EventSequences.EventLog.wait_for_sequence_number/2` — no sleeps. Removing the event store and namespace afterward is not possible yet: see [Limits and gaps](/chronicle/clients/elixir/limits-and-gaps/). # Transactions and Unit of Work > Unit-of-work transactions are now documented as a shared Chronicle workflow with synchronized client examples. Unit-of-work transactions are now documented as a shared Chronicle workflow with synchronized client examples. * [Transactions and unit of work](/chronicle/events/transactions/) * [Appending many events](/chronicle/events/appending-many/) * [Elixir client setup](/chronicle/clients/elixir/get-started/) ## Guarding a commit with a decision read `Chronicle.decide/2` runs a function inside a unit of work and commits it, same as `Chronicle.Transactions.UnitOfWork.with_unit_of_work/2`, except a conflicting decision read never raises: it returns `{:error, %Chronicle.ReadModels.DecisionConflict{}}` instead, so you can re-read and retry the decision. Returning `{:error, reason}` from the function rolls the unit of work back and returns `{:error, reason}`, so a rejected decision never writes. See [Dynamic Consistency Boundary](/chronicle/clients/elixir/dynamic-consistency-boundary/). `Chronicle.decide/2` and `with_unit_of_work/2` own the unit of work they begin: when they return, it is completed and its process stopped. A unit of work you start yourself with `Chronicle.Transactions.UnitOfWork.begin/1` is yours to commit or roll back and to stop with `Agent.stop/1`. After a failed `commit/1` it stays current until you retry or roll back. # Webhooks > Register Chronicle webhooks from Elixir, as discoverable modules or at runtime, and inspect the registered webhooks. `Chronicle.WebHooks` provides an idiomatic Elixir API for registering and inspecting Chronicle webhooks. Webhooks let Chronicle push observed events to external HTTP endpoints. ## Discoverable webhooks Discoverable webhooks follow the same pattern as projections and constraints: define them as modules and Chronicle exposes metadata through `__chronicle_webhook__/1`. ```elixir defmodule MyApp.WebHooks.AccountEvents do use Chronicle.WebHooks.Webhook, id: "account-events", target_url: "https://example.com/chronicle/webhooks", event_sequence_id: "event-log" alias Chronicle.WebHooks.DefinitionBuilder @impl true def define(builder) do builder |> DefinitionBuilder.with_event_type(MyApp.Events.AccountOpened) |> DefinitionBuilder.with_event_type(MyApp.Events.FundsDeposited) |> DefinitionBuilder.with_header("x-source", "my-app") |> DefinitionBuilder.with_bearer_token(System.fetch_env!("WEBHOOK_TOKEN")) end end ``` When `Chronicle.Client` starts with `otp_app: :my_app`, discoverable webhooks are found automatically and registered on startup. ```elixir {Chronicle.Client, connection_string: "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000", event_store: "banking", otp_app: :my_app} ``` You can also register explicit modules: ```elixir {Chronicle.Client, connection_string: "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000", event_store: "banking", webhooks: [MyApp.WebHooks.AccountEvents]} ``` ## Imperative registration Register a webhook without defining a module: ```elixir :ok = Chronicle.WebHooks.register( "account-events", "https://example.com/chronicle/webhooks", fn builder -> builder |> Chronicle.WebHooks.DefinitionBuilder.with_event_type(MyApp.Events.AccountOpened) |> Chronicle.WebHooks.DefinitionBuilder.with_basic_auth("chronicle", System.fetch_env!("WEBHOOK_PASSWORD")) |> Chronicle.WebHooks.DefinitionBuilder.with_header("x-source", "my-app") end ) ``` ## Builder API `Chronicle.WebHooks.DefinitionBuilder` is immutable and pipeline-friendly. ```elixir builder |> DefinitionBuilder.on_event_sequence("audit-sequence") |> DefinitionBuilder.with_event_type(MyApp.Events.AccountOpened) |> DefinitionBuilder.with_basic_auth("user", "password") |> DefinitionBuilder.with_bearer_token("token") |> DefinitionBuilder.with_oauth("https://login.example.com", "client-id", "client-secret") |> DefinitionBuilder.with_header("x-source", "my-app") |> DefinitionBuilder.not_replayable() |> DefinitionBuilder.not_active() ``` If you don’t select any event types, the webhook uses the event types the client was given in `:event_types` or discovered in your application. When that list is empty, for example with `discover: false` and no `:event_types`, it falls back to every event type module loaded in the VM. Select event types explicitly to control exactly what the webhook receives. `not_replayable()` and `not_active()` need Chronicle kernel 19.33.0 or later. An older kernel ignores these flags and treats the webhook as replayable and active regardless. ## Query and remove webhooks ```elixir {:ok, webhooks} = Chronicle.WebHooks.all() :ok = Chronicle.WebHooks.remove("account-events") ``` Registered webhooks are returned as `%Chronicle.WebHooks.Definition{}` with: * `:id` * `:event_sequence_id` * `:event_types` * `:target` * `:replayable?` * `:active?` The nested `%Chronicle.WebHooks.Target{}` contains: * `:url` * `:headers` * `:authorization` When you build a definition locally, authorization is one of: * `{:basic, %{username: ..., password: ...}}` * `{:bearer, %{token: ...}}` * `{:oauth, %{authority: ..., client_id: ..., client_secret: ...}}` * `nil` Chronicle doesn’t return credentials when you read webhooks back, so `:authorization` is always `nil` on definitions from `Chronicle.WebHooks.all/1`. ## Manual discovery ```elixir webhooks = Chronicle.WebHooks.discover() :ok = Chronicle.WebHooks.register_discovered() ``` ## Top-level convenience helpers `Chronicle` also delegates to the webhook API: ```elixir {:ok, webhooks} = Chronicle.get_webhooks() :ok = Chronicle.register_discovered_webhooks() :ok = Chronicle.remove_webhook("account-events") ``` # Chronicle for Kotlin and Java > Install and configure the Chronicle JVM client for Kotlin and Java, with requirements, compatibility, and links to the shared Chronicle topics. The Chronicle JVM client lets a Kotlin or Java application append events, build read models from them, and react to them, with annotations on ordinary data classes and records. Kotlin gets a coroutine API; Java gets the same surface as blocking calls. One artifact, `io.cratis:chronicle`, serves both. New here? Start with [Get started](/chronicle/clients/kotlin/get-started/). This section covers what is specific to the JVM: installation, connection setup, Spring Boot, annotations, and the API reference. Shared Chronicle concepts and workflows live in the main Chronicle docs and show Kotlin and Java tabs next to the other clients. ## Shared Chronicle topics * [Get started](/chronicle/get-started/) * [Events and event logs](/chronicle/events/) * [Appending events](/chronicle/events/appending/) * [Read models](/chronicle/read-models/) * [Projections](/chronicle/projections/) * [Reactors](/chronicle/reactors/) * [Reducers](/chronicle/reducers/) * [Constraints](/chronicle/constraints/) * [Event seeding](/chronicle/event-seeding/) * [Compliance](/chronicle/compliance/) * [Transactions and unit of work](/chronicle/events/transactions/) * [Event evolution](/chronicle/understanding-event-evolution/) ## Kotlin and Java setup You need a running Chronicle kernel. For a first try, the development image runs on its own: ```shell docker run --rm -p 127.0.0.1:35000:35000 cratis/chronicle:latest-development ``` For a setup closer to production, with storage in its own container, use Docker Compose. Chronicle serves gRPC, the API, and health checks on port `35000` over TLS with a self-signed development certificate. The ports below are bound to loopback so the development kernel and database are not reachable from other machines: ```yaml services: chronicle: image: cratis/chronicle:latest-development-slim depends_on: - mongodb - mongodb-init environment: - ASPNETCORE_ENVIRONMENT=Development - Cratis__Chronicle__Storage__Type=MongoDB - Cratis__Chronicle__Storage__ConnectionDetails=mongodb://mongodb:27017/?directConnection=true ports: - "127.0.0.1:35000:35000" mongodb: image: mongo:8.2 command: ["mongod", "--replSet", "rs0", "--bind_ip_all"] ports: - "127.0.0.1:27017:27017" # Chronicle's MongoDB storage needs a replica set — initiate it once. mongodb-init: image: mongo:8.2 depends_on: - mongodb restart: "no" command: - /bin/bash - -lc - | until mongosh --host mongodb --quiet \ --eval "db.adminCommand('ping')" >/dev/null 2>&1; do sleep 1 done mongosh --host mongodb --quiet --eval " try { rs.status(); } catch (e) { rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'mongodb:27017' }] }); }" ``` Chronicle is ready once `curl -sk https://localhost:35000/health` reports `Healthy`. The `Samples/Kotlin/Console` and `Samples/Java/Console` folders in the repository contain this file along with PostgreSQL, SQL Server, and SQLite profiles. Then add the client to your Gradle build. The pages in this section were checked against `6.5.0`; the [latest release](https://central.sonatype.com/artifact/io.cratis/chronicle) is on Maven Central: ```kotlin dependencies { implementation("io.cratis:chronicle:6.5.0") } ``` Maven uses the same coordinates, `io.cratis:chronicle:6.5.0`. The same JVM client supports both Kotlin and Java. Shared pages include separate Kotlin and Java tabs when both examples exist. Spring Boot applications add the starter instead. It brings the client with it and wires everything up: ```kotlin dependencies { implementation("io.cratis:chronicle-spring-boot-starter:6.5.0") } ``` ## Requirements and compatibility * **Client**, `io.cratis:chronicle`: Java 17 or later, and `kotlinx-coroutines` 1.10.2 or later at runtime. * **Spring Boot starter**, `io.cratis:chronicle-spring-boot-starter`: Spring Boot 4 (built against 4.1.1); the per-request features need the servlet stack. * **In-process testing**, `io.cratis:chronicle-testing`: any test runner and no kernel. All three are released together with the same version number. The client ships with Kotlin 2.4 and brings the Kotlin standard library with it, so a Java application needs no Kotlin setup. The client checks compatibility with the kernel every time it connects, and refuses to send anything to a kernel that cannot serve it. There is no published version matrix. Client 6.5.0 has been exercised against kernel 19.6.1. Kernel 19.4.8 rejects it at connect time. Treat other pairings as unverified until that check and your own tests pass. See [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/#compatibility). ## Client-specific pages * [Get Started](/chronicle/clients/kotlin/get-started/): install the JVM client, connect, append an event, and read the read model it produces, in Kotlin and Java * [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/): how artifacts are discovered and registered, and how to narrow or turn that off * [Spring Boot](/chronicle/clients/kotlin/guides/spring-boot/): the starter, multi-tenancy, and per-request identity, causation and units of work * [Testing](/chronicle/clients/kotlin/guides/testing/): specify appends and reducers in-process, without a kernel * [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/): what happens on connect, reconnect and failure, and how to troubleshoot it * [Reference](/chronicle/clients/kotlin/reference/): annotations, configuration, and service API details * [All JVM guides](/chronicle/clients/kotlin/guides/) # Concepts > Where the shared Chronicle concept pages live, and which JVM pages cover syntax. Chronicle concepts are documented once in the shared Chronicle docs. The Kotlin client pages only cover Kotlin and Java setup, annotations, and API details. Use these shared pages for the concepts: * [Events](/chronicle/concepts/event/) * [Event sources](/chronicle/concepts/event-source/) * [Observers](/chronicle/concepts/observers/) * [Read models](/chronicle/read-models/) * [Projections](/chronicle/projections/) * [Reactors](/chronicle/reactors/) * [Reducers](/chronicle/reducers/) Shared examples use synchronized C#, Kotlin, Java, Elixir, and TypeScript tabs when the client APIs differ. For JVM-specific syntax, see the [annotation reference](/chronicle/clients/kotlin/reference/annotations/). # Events > Where the shared Chronicle explanation of events lives, for Kotlin and Java readers. Events are documented in the shared Chronicle docs so the concepts stay the same across C#, Kotlin, Java, Elixir, and TypeScript. * [Event concept](/chronicle/concepts/event/) * [Event types](/chronicle/concepts/event-type/) * [Event source identity](/chronicle/concepts/event-source/) * [Appending events](/chronicle/events/appending/) * [Event evolution](/chronicle/understanding-event-evolution/) For JVM syntax, see the [annotation reference](/chronicle/clients/kotlin/reference/annotations/). # Observers > Where the shared Chronicle explanation of observers lives, for Kotlin and Java readers. Observers are documented in the shared Chronicle docs. Use those pages for the conceptual model and examples that apply across clients. * [Observer concepts](/chronicle/concepts/observers/) * [Reactors](/chronicle/reactors/) * [Reducers](/chronicle/reducers/) * [Projections](/chronicle/projections/) For JVM syntax, see the [annotation reference](/chronicle/clients/kotlin/reference/annotations/) and [EventStore API reference](/chronicle/clients/kotlin/reference/event-store-api/). # Read Models > Where the shared Chronicle explanation of read models lives, for Kotlin and Java readers. Read models are documented in the shared Chronicle docs so the model stays consistent across clients. * [Read models](/chronicle/read-models/) * [Getting a single read model instance](/chronicle/read-models/getting-single-instance/) * [Projections](/chronicle/projections/) * [Reducers](/chronicle/reducers/) For JVM syntax, see the [annotation reference](/chronicle/clients/kotlin/reference/annotations/) and [EventStore API reference](/chronicle/clients/kotlin/reference/event-store-api/). ## Kotlin client: `IReadModelsService` Beyond `getInstanceByKey`, the Kotlin client’s `store.readModels` exposes a richer surface for reading and managing read model instances: | Member | Use it for | | --- | --- | | `getInstances` | Every instance; see the EventStore API reference. | | `getSnapshotsById` | Snapshots of a read model grouped by correlation id. | | `watch` | A `Flow` of changesets for a read model — a live view. | | `dehydrateSession` | Releasing session-scoped state for an instance. | | `release`/`releaseMany` | Decrypts `@Pii` properties, one or many. | | `materialized` | Paginated, server-materialized reads — see below. | To react to changes without collecting a `Flow` yourself, write a convention-dispatched reactor instead — see [Read Model Reactors](/chronicle/clients/kotlin/guides/read-model-reactors/). `getSnapshotsById` and `watch` both deserialize straight into the read model type you ask for — `getSnapshotsById(EmployeeProfile::class, key)` returns `List>`, and `watch(EmployeeProfile::class)` returns `Flow>` — no manual JSON parsing either way. ```kotlin val employees = store.readModels.getInstances(EmployeeProfile::class) employees.forEach { employee -> println("${employee.firstName} ${employee.lastName}") } ``` ### Materialized (server-side) reads `store.readModels.materialized` provides paginated access to instances a sink has already materialized server-side: ```kotlin val page = store.readModels.materialized.getInstances( EmployeeProfile::class, skip = 0, take = 50 ) store.readModels.materialized .observeInstances(EmployeeProfile::class, skip = 0, take = 50) .collect { updatedPage -> /* re-render whenever the page changes */ } ``` `getInstances` returns a single page; `observeInstances` returns a `Flow` that emits a fresh page every time the underlying data changes. # Get started > Add the Chronicle JVM client to a Kotlin or Java project, connect to a local kernel, append an event, and read the resulting read model. You have a Kotlin or Java service and want its state to come from a record of facts instead of rows that get overwritten. This page takes you from an empty Gradle project to an application that appends an `EmployeeHired` event, reacts to it, and reads the `EmployeeProfile` it produces, in both Kotlin and Java. The steps are excerpts of one program. Put the declarations in your source set, and run the calls in order from one `main` function (Kotlin) or one `main` method (Java). ## Prerequisites * JDK 17 or later. The client is compiled for Java 17. * Gradle 8 or later, or Maven. * A running Chronicle kernel on `localhost:35000`. For the quickest start, run the development image, which bundles its own storage and binds only to loopback here: ```shell docker run --rm -p 127.0.0.1:35000:35000 cratis/chronicle:latest-development ``` The kernel is ready when `curl -sk https://localhost:35000/health` prints `Healthy`. See the [overview](/chronicle/clients/kotlin/) for a Docker Compose setup with separate storage. ## 1. Add the dependency The client is published to Maven Central as `io.cratis:chronicle`. The examples on this page were checked against `6.5.0`; use the [latest release](https://central.sonatype.com/artifact/io.cratis/chronicle) for a new project. ### Kotlin Setup build.gradle.kts ```kotlin dependencies { implementation("io.cratis:chronicle:6.5.0") } ``` ### Java Setup build.gradle ```groovy dependencies { implementation 'io.cratis:chronicle:6.5.0' } ``` With Maven: ```xml io.cratis chronicle 6.5.0 ``` The client brings the Kotlin standard library and `kotlinx-coroutines` with it, so a Java project needs nothing else. It runs on `kotlinx-coroutines` 1.10.2 or later, which includes the version Spring Boot 4.1 manages. ## 2. Connect to the kernel `ChronicleClient` is the entry point, and it takes a `ChronicleOptions`. For local development use the `development()` factory, which points at `localhost:35000` over TLS with the kernel’s development credentials: ### Kotlin Development Setup ```kotlin import io.cratis.chronicle.ChronicleClient import io.cratis.chronicle.ChronicleOptions val client = ChronicleClient(ChronicleOptions.development()) val store = client.getEventStore("MyApp") ``` ### Java Development Setup Java goes through `BlockingChronicleClient`, which is the same client with the coroutines taken off. See [step 3](#3-suspend-functions-and-java-interop). ```java import io.cratis.chronicle.ChronicleOptions; import io.cratis.chronicle.java.BlockingChronicleClient; import io.cratis.chronicle.java.BlockingEventStore; var client = BlockingChronicleClient.connect(ChronicleOptions.development()); BlockingEventStore store = client.getEventStore("MyApp"); ``` Creating the client opens the connection straight away and checks that the kernel speaks a compatible protocol. If the kernel is unreachable, or incompatible, the constructor (or `connect`) throws, typically an `io.grpc.StatusException` with status `UNAVAILABLE`. After that first connection the client reconnects on its own. See [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/) for what happens when. `development()` skips certificate validation so that it accepts the kernel’s self-signed development certificate. Use it only against a kernel on your own machine. For anything else, supply a connection string and turn certificate validation on. The grammar is `chronicle://:@[:][,...][?]`; see [Configuration](/chronicle/clients/kotlin/reference/configuration/) for the full set of hosts, options, and the `chronicle+srv://` form. Keep the secret out of source code. The connection string is not URL-decoded and everything after the first `?` is read as options, so a secret containing `?` needs the `ChronicleConnectionString` constructor instead; see [Configuration](/chronicle/clients/kotlin/reference/configuration/#tls-and-authentication): ### Kotlin Production Setup ```kotlin val secret = System.getenv("CHRONICLE_CLIENT_SECRET") ?: error("Set CHRONICLE_CLIENT_SECRET to the secret for my-client") val client = ChronicleClient( ChronicleOptions.fromConnectionString( "chronicle://my-client:$secret@chronicle.internal:35000" + "?skipTlsValidation=false" ) ) ``` ### Java Production Setup ```java String secret = System.getenv("CHRONICLE_CLIENT_SECRET"); if (secret == null) { throw new IllegalStateException( "Set CHRONICLE_CLIENT_SECRET to the client secret for my-client"); } var client = BlockingChronicleClient.connect( ChronicleOptions.fromConnectionString( "chronicle://my-client:" + secret + "@chronicle.internal:35000" + "?skipTlsValidation=false" ) ); ``` Certificate validation is off unless you turn it on Every connection string defaults to `skipTlsValidation=true`, not only the development one. Without `?skipTlsValidation=false`, the client accepts any server certificate and sends its credentials to whoever answers. ## 3. Suspend functions and Java interop Every call that talks to the kernel is a Kotlin `suspend` function. That covers appending, registering and querying. In Kotlin, call them from a coroutine; `runBlocking` is fine for a console application: ```kotlin import kotlinx.coroutines.runBlocking fun main() = runBlocking { val client = ChronicleClient(ChronicleOptions.development()) val store = client.getEventStore("MyApp") // everything below happens inside this coroutine client.dispose() } ``` Java cannot call a `suspend` function at all, because on the JVM it carries a hidden continuation parameter. So Java uses `BlockingChronicleClient`, the same client with the waiting done for it: ```java import io.cratis.chronicle.ChronicleOptions; import io.cratis.chronicle.java.BlockingChronicleClient; class JavaMain { void run() { var options = ChronicleOptions.development(); try (var client = BlockingChronicleClient.connect(options)) { var store = client.getEventStore("MyApp"); // everything below is an ordinary blocking call } } } ``` Each call blocks its thread until the kernel answers, which suits a `main` method, a servlet controller or a scheduled job. Do not call the blocking client from inside a coroutine; Kotlin code should use the suspending API. `unwrap()` on any blocking object returns the suspending object underneath, and `io.cratis.chronicle.java` also holds lower-level static bridges for the corners the blocking client does not wrap. See [Java interop](/chronicle/clients/kotlin/reference/event-store-api/#java-interop). ## 4. Define an event type Annotate a data class or Java record with `@EventType`. The class name is used as the identifier, so no argument is needed. ### Kotlin Event Definition ```kotlin import io.cratis.chronicle.events.EventType @EventType data class EmployeeHired( val employeeId: String = "", val firstName: String = "", val lastName: String = "", val department: String = "" ) ``` ### Java Event Definition ```java import io.cratis.chronicle.events.EventType; @EventType public record EmployeeHired( String employeeId, String firstName, String lastName, String department ) {} ``` ## 5. Append an event You do not register anything first. Chronicle needs the schema for an event type before it accepts events of that type, and the same goes for every other artifact your application owns. The client finds them on the classpath and declares them to the kernel as it connects, and the first append waits for that first registration pass to finish. Prefer to register by hand? Turn discovery off with `ChronicleOptions.development().withoutAutoRegistration()` and call the services yourself, for example `store.eventTypes.register(EmployeeHired::class)`. See [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/) for both paths in full. ### Kotlin Append Event ```kotlin val employeeId = "emp-001" val result = store.eventLog.append( eventSourceId = employeeId, event = EmployeeHired( employeeId = employeeId, firstName = "Jane", lastName = "Smith", department = "Engineering" ) ) if (result.isSuccess) { println("Appended at sequence ${result.sequenceNumber.value}") } else { println("Failed: ${result.constraintViolations.map { it.message }} ${result.errors}") } ``` ### Java Append Event ```java import java.util.stream.Collectors; String employeeId = "emp-001"; var result = store.getEventLog().append( employeeId, new EmployeeHired( employeeId, "Jane", "Smith", "Engineering" ) ); if (result.isSuccess()) { System.out.println("Appended at sequence " + result.getSequenceNumberValue()); } else { String violations = result.getConstraintViolations().stream() .map(v -> v.getMessage()) .collect(Collectors.joining(", ")); System.out.println("Failed: " + violations + " " + result.getErrors()); } ``` A constraint or concurrency violation does not throw: `isSuccess` is `false`, and the result carries the violations or errors. Two kinds of failure do throw. When the kernel refuses the command itself, for example because the client is not authorized, the client throws `ChronicleCommandRejected`. A transport failure, such as a lost connection, is thrown as a gRPC `StatusException`. `sequenceNumber` is an `EventSequenceNumber`, a Kotlin value class whose getter has a mangled JVM signature Java cannot name. That is why `AppendResult` carries `getSequenceNumberValue()` for reading the position as a plain `long`. Java never has to construct a value class either, because nothing Java-facing asks for one. ## 6. React to events A reactor observes events and performs side effects (see [Reactors](/chronicle/reactors/) for the full model). Annotate the class with `@Reactor` and write one method per event type you want to handle. The first parameter type is what selects the events a method receives; the method name is free. ### Kotlin Reactor ```kotlin import io.cratis.chronicle.observation.Reactor @Reactor class HrNotifications { fun employeeHired(event: EmployeeHired) { println("Welcome ${event.firstName} ${event.lastName} " + "to ${event.department}!") } } ``` The client finds it and starts the observation on connect. With discovery turned off, register it by hand instead. Do not do both: every `register` call opens its own observation stream for the reactor. ```kotlin store.reactors.register(HrNotifications()) ``` ### Java Reactor ```java import io.cratis.chronicle.observation.Reactor; @Reactor public class HrNotifications { public void employeeHired(EmployeeHired event) { System.out.println("Welcome " + event.firstName() + " " + event.lastName() + " to " + event.department() + "!"); } } ``` The client finds it and starts the observation on connect. With discovery turned off, register it by hand instead: ```java store.getReactors().register(new HrNotifications()); ``` The reactor runs after the append has been stored, on its own schedule. The `Welcome` line can appear before or after the next line of your program. ## 7. Build a read model A reducer folds a stream of events into a single object (see [Reducers](/chronicle/reducers/) for the full model). `@ReadModel` marks the read model class, and `@Reducer` marks the reducer. A reducer method takes the event, and optionally the current state. The state is `null` for the first event of an event source, so declare that parameter as nullable and fall back to a fresh instance. A reducer registers its read model too, tagged with the reducer that produces it, so a read model only needs registering on its own when nothing projects into it. All of it happens automatically; the `register` calls below are what you would write with discovery turned off. ### Kotlin Read Model ```kotlin import io.cratis.chronicle.readModels.ReadModel import io.cratis.chronicle.observation.Reducer @ReadModel data class EmployeeProfile( val id: String = "", val firstName: String = "", val lastName: String = "", val department: String = "" ) @Reducer class EmployeeProfileReducer { fun employeeHired( event: EmployeeHired, state: EmployeeProfile? ): EmployeeProfile = (state ?: EmployeeProfile()).copy( id = event.employeeId, firstName = event.firstName, lastName = event.lastName, department = event.department ) } ``` ```kotlin store.reducers.register(EmployeeProfileReducer()) ``` ### Java Read Model Java needs one file per public type, so the read model and the reducer are two files. ```java import io.cratis.chronicle.readModels.ReadModel; @ReadModel public class EmployeeProfile { private String id = ""; private String firstName = ""; private String lastName = ""; private String department = ""; public EmployeeProfile() {} // Getters and setters public String getId() { return id; } public void setId(String id) { this.id = id; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public String getDepartment() { return department; } public void setDepartment(String department) { this.department = department; } } ``` ```java import io.cratis.chronicle.observation.Reducer; @Reducer public class EmployeeProfileReducer { public EmployeeProfile employeeHired(EmployeeHired event, EmployeeProfile state) { EmployeeProfile result = state != null ? state : new EmployeeProfile(); result.setId(event.employeeId()); result.setFirstName(event.firstName()); result.setLastName(event.lastName()); result.setDepartment(event.department()); return result; } } ``` ```java store.getReducers().register(new EmployeeProfileReducer()); ``` ## 8. Query a read model by key The reducer runs in the background after the append is stored, and its result is written to the read model store a moment later. So a read straight after the append can come back `null`, or with the state from before the event. A successful append means the event is stored, not that every read model has caught up with it. For this walkthrough, wait for the instance with an explicit time limit, and fail clearly when it does not appear: ### Kotlin Query ```kotlin import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeoutOrNull val profile = withTimeoutOrNull(10_000) { var current = store.readModels.getInstanceByKey(EmployeeProfile::class, employeeId) while (current == null) { delay(100) current = store.readModels.getInstanceByKey(EmployeeProfile::class, employeeId) } current } ?: error("EmployeeProfile $employeeId was not available within 10 seconds") println(profile.firstName) // Jane ``` ### Java Query ```java import java.time.Duration; import java.time.Instant; Instant deadline = Instant.now().plus(Duration.ofSeconds(10)); EmployeeProfile profile = store.getReadModels() .getInstanceByKey(EmployeeProfile.class, employeeId); while (profile == null && Instant.now().isBefore(deadline)) { Thread.sleep(100); profile = store.getReadModels() .getInstanceByKey(EmployeeProfile.class, employeeId); } if (profile == null) { throw new IllegalStateException( "EmployeeProfile " + employeeId + " was not available within 10 seconds"); } System.out.println(profile.getFirstName()); // Jane ``` The Kotlin `withTimeoutOrNull` also cancels a read that is still in flight. The Java loop only checks its deadline between reads, and a single blocking read has no time limit of its own, so it can overrun when the kernel stops answering. This loop waits for the instance to exist. When you are waiting for a change to an instance that already exists, check for the value you expect instead of `null`. Polling after every write is a teaching device, not an application pattern. In an application, return the append result to the caller and let screens observe the read model, or design the next step so it does not depend on the read model having caught up. When a read must include the latest events, make the reducer passive with `@Reducer(isActive = false)` (see [`@Reducer`](/chronicle/clients/kotlin/reference/annotations/#reducer)): the read model is then computed from its events on every read instead of stored. The shared [read model consistency](/chronicle/read-models/) docs explain the trade-offs. ## Troubleshooting **The constructor or `connect` throws `StatusException: UNAVAILABLE`.** The kernel is not running, not on that host and port, or the TLS settings do not match it. Check `curl -sk https://localhost:35000/health`. With `skipTlsValidation=false`, make sure the JVM trusts the kernel’s certificate. **The first append or `awaitRegistration()` throws `ChronicleConnectionFailed` mentioning `UNAUTHENTICATED`.** The client id or secret is wrong. Fix the credentials; the client keeps retrying and recovers once they are accepted. **stderr shows `Automatic registration of artifacts failed`.** An artifact could not be registered or created, and the artifacts after it in the registration order were not registered. See [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/#when-registration-fails). **The read model is `null` right after appending.** It has not caught up yet. That is expected; wait with a time limit, as in step 8. ## What’s next * [Guides](/chronicle/clients/kotlin/guides/): Spring Boot, artifact registration, testing, seeding, and the other JVM-specific topics * [Concepts](/chronicle/clients/kotlin/concepts/): where the shared explanations of events, observers and read models live * [Reference](/chronicle/clients/kotlin/reference/): annotations, the `IEventStore` API, configuration, and the connection lifecycle # Guides > Where to find Chronicle task guides for Kotlin and Java, and the guides that only apply to the JVM client. Chronicle task guides are shared across clients. They live in the main Chronicle docs and use synchronized language tabs for Kotlin, Java, C#, Elixir, and TypeScript examples. Start with: * [Appending events](/chronicle/events/appending/) * [Read models](/chronicle/read-models/) * [Projections](/chronicle/projections/) * [Reactors](/chronicle/reactors/) * [Reducers](/chronicle/reducers/) * [Constraints](/chronicle/constraints/) * [Event evolution](/chronicle/understanding-event-evolution/) ## Guides for the JVM client These cover what only the Kotlin and Java client does: * [Wiring Chronicle into Spring Boot](/chronicle/clients/kotlin/guides/spring-boot/) * [Controlling what gets registered, and when](/chronicle/clients/kotlin/guides/artifact-registration/) * [Testing a slice without a kernel](/chronicle/clients/kotlin/guides/testing/) * [Declaring strongly-typed identifiers](/chronicle/clients/kotlin/guides/concepts/) * [Seeding events](/chronicle/clients/kotlin/guides/seeding/) * [Event sources and streams](/chronicle/clients/kotlin/guides/event-sources/) * [Reacting to read model changes](/chronicle/clients/kotlin/guides/read-model-reactors/) * [Capturing external sources](/chronicle/clients/kotlin/guides/captures/) * [Registering external services](/chronicle/clients/kotlin/guides/external-services/) * [Tracing with OpenTelemetry](/chronicle/clients/kotlin/guides/tracing/) * [Correlation](/chronicle/clients/kotlin/guides/correlation/) * [Migrating append routing](/chronicle/clients/kotlin/guides/migrate-append-routing/) * [Migrating to Fundamentals](/chronicle/clients/kotlin/guides/migrate-to-fundamentals/) Use the Kotlin client section for [setup](/chronicle/clients/kotlin/get-started/) and [API reference](/chronicle/clients/kotlin/reference/). # Appending Events > Where the shared guide to appending events lives, with Kotlin and Java tabs. Appending events is documented in the shared Chronicle docs with synchronized examples for C#, Kotlin, Java, Elixir, and TypeScript. * [Appending events](/chronicle/events/appending/) * [Appending many events](/chronicle/events/appending-many/) * [Event source identity](/chronicle/events/event-source-id/) * [Concurrency](/chronicle/events/concurrency/) Use the [Kotlin get started page](/chronicle/clients/kotlin/get-started/) for JVM setup before running the shared examples. For the breaking change to omitted routing defaults and explicit legacy routes, see [Migrate append routing](/chronicle/clients/kotlin/guides/migrate-append-routing/). ## Date and time properties Event and read model properties can use `java.time` types. The client writes each as an ISO-8601 string, which is what the schema it registers describes: | Type | Schema `format` | Written as | | --- | --- | --- | | `Instant` | `date-time-offset` | `2024-05-01T10:15:30Z` | | `OffsetDateTime` | `date-time-offset` | `2024-05-01T10:15:30+02:00` | | `ZonedDateTime` | `date-time-offset` | `2024-05-01T10:15:30+02:00` | | `LocalDateTime` | `date-time` | `2024-05-01T10:15:30` | | `LocalDate` | `date` | `2024-05-01` | | `LocalTime` | `time` | `10:15:30` | A `ZonedDateTime` is written as an offset date-time, so it keeps the instant and the offset but not the zone id. Read back, its zone is the offset (`+02:00`), not `Europe/Oslo`. Use `Instant` or `OffsetDateTime` when the zone does not matter, or store the zone id in a separate string property when it does. Values written by the .NET client read back correctly, including a UTC `DateTimeOffset` written as `+00:00`. `Duration` and `ByteArray` are not covered by these adapters yet and have not been verified against the .NET wire format. ```kotlin import io.cratis.chronicle.events.EventType import java.time.Instant import java.time.LocalDate @EventType data class ShipmentDispatched(val dispatchedAt: Instant, val deliveryDate: LocalDate) ``` ## Kotlin client: `IEventSequence` and `IEventLog` Beyond `append`/`appendMany`, the Kotlin client’s `store.eventLog` (and any sequence from `store.getEventSequence`) exposes a richer surface for reading, redacting, and observing events: | Member | Use it for | | --- | --- | | `appendMany(events)` | One atomic batch spanning several event sources. | | `getTailSequenceNumber` | Current end of the sequence or one source. | | `getForEventSourceIdAndEventTypes` | Events for one source, by type. | | `getFromSequenceNumber` | Events forward from a position (a bookmark). | | `getNextSequenceNumber` | Sequence number the next append will get. | | `completeStream` | Closes a stream so it can’t be appended to again. | | `redact`/`redactForEventSource` | Erases event content — see below. | | `appendOperations` | Hot `Flow` of appends made through this instance. | See the [EventStore API reference](/chronicle/clients/kotlin/reference/event-store-api/) for the full `IEventSequence` interface and `ConcurrencyScope` (optimistic concurrency via `AppendOptions.concurrencyScope`). ### Appending across event sources `appendMany(eventSourceId, events)` shapes the whole batch around one event source. When a single unit of work touches several — moving money between two accounts, say — pass `EventForEventSourceId` records instead. Each carries its own event source id and its own shaping, and the batch still commits as one atomic append: ```kotlin import io.cratis.chronicle.events.EventType @EventType data class OrderLineAdded(val sku: String, val quantity: Int) @EventType data class StockReserved(val sku: String, val quantity: Int) ``` ```kotlin import io.cratis.chronicle.eventSequences.EventForEventSourceId store.eventLog.appendMany( listOf( EventForEventSourceId("order-1", OrderLineAdded("sku-9", 2)), EventForEventSourceId("sku-9", StockReserved("sku-9", 2)) ) ) ``` Pass `concurrencyScopes` to check specific event sources optimistically — it is keyed by event source id, and any source left out is appended unchecked. ### Composing a batch across call sites Sometimes the events for one unit of work are not all decided in the same place. `forEventSourceId` starts a composed operation you can build up and inspect before anything is sent, then commit with a single `perform()`: ```kotlin import io.cratis.chronicle.eventSequences.EventSequenceNumber import io.cratis.chronicle.eventSequences.operations.forEventSourceId val operations = store.eventLog.forEventSourceId("order-1") { withConcurrencyScope { withSequenceNumber(EventSequenceNumber(4)).withEventSourceId() } append(OrderLineAdded("sku-9", 2)) } operations.forEventSourceId("sku-9") { append(StockReserved("sku-9", 2), tags = listOf("inventory")) } // Nothing has reached the kernel yet - this is exactly what perform() will send. println(operations.getEventsToAppend()) val results = operations.perform() ``` Concurrency lives on the event source rather than on an individual event, because that is where the kernel checks it. A source that never asks for a scope is appended unchecked, and a scope already set is never cleared by a later call that expresses no expectation. ### Redacting events `redact` and `redactForEventSource` permanently rewrite event content — a destructive, irreversible operation, not a soft delete or a field mask. Once either call returns, the original content is gone from the event store for good. Use them only for a confirmed compliance/erasure request: ```kotlin import io.cratis.chronicle.eventSequences.EventSequenceNumber import io.cratis.chronicle.eventSequences.RedactionReason val reason = RedactionReason("GDPR erasure request") store.eventLog.redact(EventSequenceNumber(42), reason) store.eventLog.redactForEventSource("customer-1", reason) ``` # Artifact Registration > How the JVM client discovers event types, read models, observers, constraints and other artifacts, registers them in order, and how to narrow, replace or turn that off. Chronicle’s kernel has to know what your application is made of before it can do anything useful with it. Event types need schemas registered so events can be validated and migrated. Reducers and projections need to be declared so the kernel knows what to feed them. Constraints need to exist before the append they are meant to stop. The client does all of that for you. Every class carrying `@EventType`, `@ReadModel`, `@Reactor`, `@Reducer`, `@Constraint`, `@FromEvent`, or implementing `IProjectionFor`, `ICanSeedEvents` or `IWebhookDefiner` is found on the classpath and registered the moment the client connects — in the order the kernel needs them. ## The short version Write your artifacts. Connect. That is the whole setup: ```kotlin val client = ChronicleClient(ChronicleOptions.development()) val store = client.getEventStore("MyApp") store.awaitRegistration() store.eventLog.append("employee-1", EmployeeHired("Ada", "Lovelace", "Engineer")) ``` From Java: ```java var client = BlockingChronicleClient.connect(ChronicleOptions.development()); var store = client.getEventStore("MyApp"); store.awaitRegistration(); store.getEventLog().append("employee-1", new EmployeeHired("Ada", "Lovelace", "Engineer")); ``` `awaitRegistration()` returns as soon as the first registration pass has run. It is not required: the first append waits for the same pass. Calling it at startup moves that wait out of your first request. It has no time limit of its own, so bound it; see [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/#bound-the-first-call). In a Spring Boot application the starter already waits for it at startup, for up to `cratis.chronicle.registration-timeout`. See [Spring Boot](/chronicle/clients/kotlin/guides/spring-boot/#startup). ## What gets discovered | Artifact | Recognized by | | --- | --- | | Event type | `@EventType` | | Event type migration | Implements `IEventTypeMigration` | | Read model | `@ReadModel` | | Projection | Implements `IProjectionFor` | | Model-bound projection | Read model carrying `@FromEvent` | | Reactor | `@Reactor` | | Reducer | `@Reducer` | | Constraint | Implements `IConstraint` | | Event seeder | Implements `ICanSeedEvents` | | Webhook | Implements `IWebhookDefiner` | | Capture | Implements `ICapture` | | Reactor middleware | Implements `IReactorMiddleware` | | Reactor argument resolver | Implements `IReactorMethodArgumentResolver` | Only concrete classes qualify — interfaces and abstract classes are skipped, so your own base types never get registered by accident. The two reactor entries are client-side: they are never declared to the kernel, they take part in how a reactor handler is invoked. They are discovered here because they answer the same question — what is this application made of? See [Reactor middlewares](#reactor-middlewares) below. External services and event store subscriptions are configuration rather than artifacts: they describe systems outside your application, so they are still set up explicitly through `store.externalServices` and `store.eventStoreSubscriptions`. ## The order things are registered in Order matters, and the client gets it right so you do not have to think about it: 1. **Event types and their migrations**, together in one call, because everything else is expressed in terms of event type ids and because the kernel merges an event type’s generations from a single registration. 2. **Read models that no observer produces.** A read model built by a reducer or a projection is registered by that observer instead, which is the only place the observer’s type and identity are known. 3. **Constraints, projections and webhooks.** 4. **Reactors and reducers**, which start observing. 5. **Captures**, which start appending the moment they run, so like seeding they go behind every observer that should see what they bring in. 6. **Seeders**, last of all — the kernel appends seeded events immediately, so every observer that should see them has to be watching first. Registration runs again on every reconnect, because a kernel that restarted has forgotten what it was told. Reactors and reducers are started only once: each one re-establishes its own observation when the connection comes back. ## When registration fails Reactors and reducers are started one at a time. When one cannot be started, the client reports it and carries on with the rest, including captures and seeders: ```text [ArtifactRegistrations] Reactor 'com.acme.Notifications' could not be started: ``` The failed one is tried again on the next registration pass, after a reconnect or when you call `registerAll()`; one that started is never started twice. The other steps (event types, read models, constraints, projections and webhooks) run as a whole. When one of them throws, the pass stops there, everything later in the order is not registered in that pass, and the client writes the failure to standard error: ```text [EventStore] Automatic registration of artifacts failed: ``` The pass runs again on the next reconnect. The waiting calls are released anyway: `awaitRegistration()` returns normally, and the first append goes ahead, failing if its event type was never registered. So a clean return from `awaitRegistration()` does not prove that registration succeeded. Check standard error after startup. Common causes: * A reactor or reducer with no handler method throws `ObserverHasNoHandlers`. A handler is a public method whose first parameter is an `@EventType` class. * An artifact the default activator cannot create throws `ArtifactActivationFailed`; see [Artifacts with dependencies](#artifacts-with-dependencies). * A reactor handler asks for a parameter nothing can supply. An `IConstraint` needs `@Constraint` as well. Without the annotation it is skipped without a message. ## Narrowing the scan Scanning the whole classpath is fine for most applications and takes a fraction of a second. In a large one, or where third-party libraries ship Chronicle artifacts of their own, point discovery at the packages you own: ```kotlin val options = ChronicleOptions.development() .withArtifactsFrom("com.acme.ordering", "com.acme.shipping") ``` Sub-packages are included, so one entry per top-level package is usually enough. ## Listing artifacts explicitly `KnownClientArtifacts` takes the list instead of finding it — useful where classpath scanning is unwanted, such as a locked-down runtime or a spec that must not see the rest of the classpath. Each class is sorted into every kind it qualifies for, so a `@ReadModel` carrying `@FromEvent` only has to be listed once: ```kotlin import io.cratis.chronicle.artifacts.KnownClientArtifacts val options = ChronicleOptions.development().copy( artifacts = KnownClientArtifacts( EmployeeHired::class, EmployeeState::class, EmployeeStateReducer::class, UniqueEmployeeEmail::class ) ) ``` ## Turning it off Manual registration is still fully supported. Turn discovery off and register whatever you like, whenever you like: ```kotlin val client = ChronicleClient(ChronicleOptions.development().withoutAutoRegistration()) val store = client.getEventStore("MyApp") store.eventTypes.register(EmployeeHired::class, EmployeePromoted::class) store.reducers.register(EmployeeStateReducer()) store.constraints.register(UniqueEmployeeEmail()) ``` From Java: ```java var options = ChronicleOptions.development().withoutAutoRegistration(); var client = BlockingChronicleClient.connect(options); var store = client.getEventStore("MyApp"); // Event types are not wrapped by the blocking store; use the static bridge. EventTypesServiceJavaBridge.register(store.unwrap().getEventTypes(), EmployeeHired.class, EmployeePromoted.class); store.getReducers().register(new EmployeeStateReducer()); ``` With auto-registration off, `awaitRegistration()` returns immediately — there is nothing to wait for. `store.registerAll()` still works, and registers everything discovery found in one call, so you can keep discovery and merely control *when* it happens. The console samples in `Samples/Kotlin/Console` and `Samples/Java/Console` deliberately opt out, so they double as a tour of the registration API. ## Artifacts with dependencies By default an artifact is created by calling a constructor that takes no arguments — either a genuine no-arg constructor, or one where every parameter has a default. That covers reactors, reducers, constraints and seeders as they are normally written. An artifact that needs collaborators should be created by a container. Supply an `IArtifactActivator` and the client will ask it instead: ```kotlin import io.cratis.chronicle.artifacts.IArtifactActivator val options = ChronicleOptions.development().copy( artifactActivator = IArtifactActivator { type -> myContainer.resolve(type) } ) ``` Spring Boot applications get this for free — see [Spring Boot](/chronicle/clients/kotlin/guides/spring-boot/). If an artifact cannot be created, the client throws `ArtifactActivationFailed` naming the class and explaining the three ways out: give it a constructor that takes nothing, give its parameters defaults, or activate it through a container. ## Reactor middlewares Tracing, logging, metrics and correlation scoping want to happen around every reactor handler and belong to none of them. Put them in an `IReactorMiddleware` and every reactor stays a description of what happens when a fact arrives: ```kotlin import io.cratis.chronicle.events.EventContext import io.cratis.chronicle.observation.IReactorMiddleware class HandlerLogging : IReactorMiddleware { override suspend fun beforeInvoke(context: EventContext, event: Any) { val name = event::class.simpleName println("Handling $name #${context.sequenceNumber} for ${context.eventSourceId}") } override suspend fun afterInvoke(context: EventContext, event: Any) { println("Handled ${event::class.simpleName} #${context.sequenceNumber}") } } ``` Writing the class is all there is to it — discovery finds it and applies it to every reactor. `beforeInvoke` runs outermost-first and `afterInvoke` in reverse, so middlewares nest the way you would write them by hand, and `afterInvoke` runs whether the handler returned or threw. One middleware instance serves every reactor, and a suspending handler can resume on a different thread, so `afterInvoke` may run on another thread than `beforeInvoke`. Keep per-invocation state out of fields and `ThreadLocal`s. Java cannot implement a suspending method, so a Java middleware implements `BlockingReactorMiddleware` — the same two methods without `suspend` — and the client adapts it onto the same chain. ## Handler parameters beyond the event A reactor handler takes the event, and optionally its `EventContext`. Anything past that is resolved per invocation, which is how a handler asks for a read model without reaching for the event log: ```kotlin @Reactor class OverdraftAlerts(private val mail: Mailer) { suspend fun moneyWithdrawn( event: MoneyWithdrawn, account: AccountBalance? ) { if ((account?.balance ?: 0.0) < 0.0) mail.overdrawn(event.accountId) } } ``` The instance is fetched for the event source the event arrived under, and is `null` when nothing has been projected for that key yet, so declare the parameter nullable. It is read from the read model store, which is updated asynchronously: it may not include the event being handled, or events just before it. Do not base a decision that must be exact on it; enforce such rules with a [constraint](/chronicle/constraints/) when the event is appended. Implement `IReactorMethodArgumentResolver` to supply anything else, a container-backed service for instance. Discovered resolvers are consulted before the built-in read model one, so an application can take over a parameter the client would otherwise claim. A parameter nothing can supply is rejected when the reactor registers, rather than failing on every event. ## Reference | Option | Default | Description | | --- | --- | --- | | `autoDiscoverAndRegister` | `true` | Register artifacts on connect | | `artifacts` | `ClientArtifacts.default` | What the application is made of | | `artifactActivator` | `ArtifactActivator` | How artifacts are created | | Method on `IEventStore` | Description | | --- | --- | | `registerAll()` | Register everything now. Safe to call repeatedly | | `awaitRegistration()` | Wait for it. Returns at once when it is off | # Captures > Declare a capture in Kotlin or Java so the kernel pulls facts from an external source and appends them as events. Not everything that matters happens inside your application. An exchange rate moves, a partner posts a webhook, a supplier’s API changes a price. Those are facts too — and the usual answer is a scheduled job somewhere that polls, maps, and appends, which nobody owns and nobody notices when it stops. A capture is that job, declared rather than written, and run by the kernel. ## Declaring one Implement `ICapture` and discovery saves it with the kernel on connect and starts it, so the source is live as soon as your application is: ```kotlin import io.cratis.chronicle.captures.ICapture class ExchangeRates : ICapture { override val id = "exchange-rates" override val declaration = """ capture ExchangeRates from api "https://api.example.com/rates" every 5 minutes append RateObserved set currency to base set rate to value """ } ``` The body is a Capture Declaration Language document. The language belongs to the kernel, which is what parses and runs it — see the [Chronicle documentation](https://github.com/Cratis/Chronicle) for its full shape. Set `startOnRegistration` to `false` to have the capture saved but held — an environment where the source is not reachable, or one where starting it is an operator’s decision. A declaration the kernel cannot parse is reported to standard error naming the capture, the line and the column. One bad capture does not take the rest of your application’s registration down with it. ## When the declaration is not known at build time `store.captures` is the whole surface, for declarations that come from somewhere else — an editor, configuration, an operator: ```kotlin // Check as it is typed, without saving anything val problems = store.captures.validate(declaration) // Save, then start when (val result = store.captures.save("exchange-rates", declaration)) { is CaptureDeclarationResult.Accepted -> store.captures.start(result.capture.id) is CaptureDeclarationResult.Rejected -> result.messages.forEach(::println) } ``` | Member | Does | | --- | --- | | `getAll()` | Every capture the store holds, running or not | | `observeAll()` | The same, re-emitted on every change | | `save(id, declaration)` | Saves under an id, replacing what was there | | `validate(declaration)` | Checks a declaration without saving anything | | `start(id)` | Starts it appending | | `stop(id)` | Stops it. It stays saved | | `delete(id)` | Removes it entirely | A declaration is a piece of text, so the first thing that can go wrong is that the kernel cannot make sense of it. That is an ordinary outcome of writing in a language rather than an exception, so `save` returns `Accepted` or `Rejected` and every message names a line and column. Messages do not by themselves mean rejection — a declaration can be accepted with something worth telling you. `isSuccess`, or the shape of the result, is what says whether it took. ## What stopping does not undo Events a capture already appended are facts. Stopping or deleting the capture changes nothing about them — they stay exactly where they are, and everything projected from them stays as it was. From Java, use `CapturesServiceJavaBridge`. # PII Compliance > Where the shared guide to PII compliance lives, with Kotlin and Java tabs. Compliance and PII handling are shared Chronicle topics. The shared docs own the concepts, storage behavior, and client-tabbed examples. * [Compliance](/chronicle/compliance/) * [PII](/chronicle/compliance/pii/) * [Compliance in read models](/chronicle/compliance/read-models/) * [Kotlin and Java client setup](/chronicle/clients/kotlin/get-started/) # Strongly-typed identifiers > Declare strongly-typed identifiers and values in Kotlin and Java, and use them as event source ids and event properties. A `String` is a `String` is a `String`. Nothing stops a book’s identifier being passed where a member’s was expected — both compile, and the bug surfaces in production as a lookup that quietly finds nothing. ```kotlin fun borrow(bookId: String, memberId: String) { } // Compiles. Wrong. borrow(memberId, bookId) ``` Give each value a type of its own and the compiler catches it while you are still typing. ## Declaring one A concept is any type implementing `ConceptAs`: ```kotlin import io.cratis.fundamentals.concepts.ConceptAs data class BookId(val value: String) : ConceptAs { override fun value(): String = value } data class MemberId(val value: String) : ConceptAs { override fun value(): String = value } data class CopyNumber(private val number: Int) : ConceptAs { override fun value(): Int = number } ``` Then use them wherever the value used to be: ```kotlin @EventType data class BookBorrowed( val book: BookId = BookId(""), val member: MemberId = MemberId("") ) // Won't compile — which is the entire point. BookBorrowed(memberId, bookId) ``` ## What goes on the wire A concept serializes as the value it wraps, not as an object wrapping one: ```json { "book": "dune", "member": "ada" } ``` That is what makes concepts something you can adopt for a property that is already in production. The JSON does not change, the schema the kernel validates against does not change, and every event stored before the concept existed still reads back. The kernel never learns that the value has a type on your side, and does not need to. The same holds for read models, reducer state, and constraint declarations — everything goes through one serializer, so a concept behaves identically wherever it appears. ## Event source ids The event source id is a `String` on the wire and always will be. So that your side of the call can still be typed, every client method taking one has an overload taking a `ConceptAs`: ```kotlin import io.cratis.chronicle.concepts.append store.eventLog.append(BookId("dune"), BookBorrowed(BookId("dune"), MemberId("ada"))) ``` These are extension functions in `io.cratis.chronicle.concepts`, so import the one you need. They cover `append`, `appendMany`, `hasEventsFor`, `getTailSequenceNumber`, `getForEventSourceIdAndEventTypes`, `redactForEventSource`, and `getInstanceByKey`. ## From Java A concept is an ordinary type, so Java declares one as a record: ```java import io.cratis.fundamentals.concepts.ConceptAs; public record BookId(String value) implements ConceptAs {} ``` The record’s `value()` accessor implements the interface directly. Read through the interface with `concept.value()` in Java or Kotlin. Kotlin can also use `concept.value` by importing `io.cratis.fundamentals.concepts.value`. For existing applications, see [Migrating to Fundamentals](/chronicle/clients/kotlin/guides/migrate-to-fundamentals/). ## Data class or value class Kotlin’s `@JvmInline value class` also works and avoids the allocation, but its mangled JVM signatures are awkward from Java. Prefer a `data class` unless the allocation genuinely matters and Java never touches the type. # Constraints > Where the shared guide to constraints lives, with Kotlin and Java tabs. Constraints are shared Chronicle behavior. The shared docs cover constraint concepts, model-bound constraints, declarative constraints, and client-tabbed examples. * [Constraints](/chronicle/constraints/) * [Understanding constraints](/chronicle/understanding-constraints/) * [Kotlin and Java client setup](/chronicle/clients/kotlin/get-started/) ## Kotlin client: constraint scoping `IConstraintBuilder` (passed into `IConstraint.define`) can scope every constraint added through it to a narrower uniqueness dimension than the whole event store: | Member | Scopes uniqueness checking per… | | --- | --- | | `perEventSourceType` | Event source type | | `perEventStreamType` | Event stream type | | `perEventStreamId` | Event stream id | Combine any of the three; by default (none called) a constraint is checked globally across the whole event store: ```kotlin import io.cratis.chronicle.constraints.Constraint import io.cratis.chronicle.constraints.IConstraint import io.cratis.chronicle.constraints.IConstraintBuilder import io.cratis.chronicle.events.EventType @EventType data class ConstraintsBridgeUserRegistered( val userId: String = "", val email: String = "" ) @Constraint class ConstraintsBridgeUniqueEmail : IConstraint { override fun define(builder: IConstraintBuilder) { builder .perEventSourceType() .unique { unique -> unique .on( ConstraintsBridgeUserRegistered::class, ConstraintsBridgeUserRegistered::email ) .withMessage("Email must be unique per event source type.") } } } ``` ## Kotlin client: unique over several properties Pass more than one property to `on` when the value that must be unique is the combination of them. Here a person is rejected only when both the first and the last name match an existing one; sharing just one of them is fine. `ignoreCasing()` applies to every property in the constraint: ```kotlin import io.cratis.chronicle.constraints.Constraint import io.cratis.chronicle.constraints.IConstraint import io.cratis.chronicle.constraints.IConstraintBuilder import io.cratis.chronicle.events.EventType @EventType data class ConstraintsBridgePersonRegistered( val firstName: String = "", val lastName: String = "" ) @Constraint class ConstraintsBridgeUniqueFullName : IConstraint { override fun define(builder: IConstraintBuilder) { builder.unique { unique -> unique .on( ConstraintsBridgePersonRegistered::class, ConstraintsBridgePersonRegistered::firstName, ConstraintsBridgePersonRegistered::lastName ) .ignoreCasing() } } } ``` Call `on` once per event type, each with its own properties, to make one constraint span several event types. Calling `on` a second time for the same event type throws `EventTypeAlreadyAddedToUniqueConstraint` - list every property in one call - and a `unique { }` block that never calls `on` throws `NoEventTypesAddedToUniqueConstraint`. When a constraint spans several event types that each have several properties, every type must list the same number of properties in the same order, because the kernel combines the values in declaration order. Earlier versions kept only the last `on` call Before this was supported, calling `on` more than once in a `unique { }` block silently replaced the earlier call, so only the last event type and property were constrained. Calls for different event types now all take effect, and a repeated call for the same event type throws. If a constraint was relying on the old behavior, check that it still covers what you meant it to. ## Java client: unique over several properties Java names the properties as strings through `UniqueConstraintBuilderJavaBridge`; pass as many as make up the unique value: ```java import io.cratis.chronicle.constraints.Constraint; import io.cratis.chronicle.constraints.IConstraint; import io.cratis.chronicle.constraints.IConstraintBuilder; import io.cratis.chronicle.events.EventType; import io.cratis.chronicle.java.UniqueConstraintBuilderJavaBridge; @EventType record ConstraintsBridgePersonRegistered(String firstName, String lastName) {} @Constraint class ConstraintsBridgeUniqueFullName implements IConstraint { @Override public void define(IConstraintBuilder builder) { builder.unique(unique -> { UniqueConstraintBuilderJavaBridge .on(unique, ConstraintsBridgePersonRegistered.class, "firstName", "lastName") .ignoreCasing(); }); } } ``` # Correlation identifiers > Chronicle stamps every append with a correlation identifier. When you do not pass one in AppendOptions.correlationId, the client falls back to the current… Chronicle stamps every append with a correlation identifier. When you do not pass one in `AppendOptions.correlationId`, the client falls back to the current thread’s ambient identifier held by `correlationIdManager`. ## The shared singleton `correlationIdManager` is bound to `CorrelationIdContext.shared()` from `io.cratis:fundamentals`. Reading, setting or clearing through either one reads, sets or clears the same per-thread value. ```kotlin import io.cratis.chronicle.correlation.correlationIdManager import io.cratis.fundamentals.execution.CorrelationId import io.cratis.fundamentals.execution.CorrelationIdContext import java.util.UUID fun bindSharedCorrelation() { val id = UUID.randomUUID() correlationIdManager.set(id) check(CorrelationIdContext.shared().current == CorrelationId(id)) correlationIdManager.clear() // the next read generates a new identifier } ``` ## Isolated managers `CorrelationIdManager()` creates a manager with its own independent state. It shares nothing with other managers or with the singleton. ## Thread boundary and cleanup * The value is stored per thread and is **not** inherited by threads you start or by executor workers. * The first read on a thread without a value generates and retains a UUID. An explicitly set zero UUID is kept as is. * Nothing is propagated across coroutine suspension or dispatcher changes, and there is no scope stack or automatic restoration. Whoever sets a value is responsible for calling `clear()`, as the Spring Boot unit-of-work filter does. ## Suspending work For suspending or multi-threaded work, capture the identifier at the entry point and pass it explicitly: ```kotlin import io.cratis.chronicle.eventSequences.AppendOptions import io.cratis.chronicle.eventSequences.IEventSequence import java.util.UUID suspend fun appendWithExplicitCorrelation( sequence: IEventSequence, event: Any, id: UUID ) { sequence.append("source-1", event, AppendOptions(correlationId = id)) } ``` An explicit `AppendOptions.correlationId` always takes precedence over the ambient value and does not change it. # Event Evolution > Where the shared guide to evolving event types lives, with Kotlin and Java tabs. Event schema evolution and migrations are shared Chronicle behavior. The shared docs own the migration model and client-tabbed examples. * [Understanding event evolution](/chronicle/understanding-event-evolution/) * [Event type migrations concept](/chronicle/concepts/event-type-migrations/) * [Migrations](/chronicle/migrations/) * [Kotlin and Java client setup](/chronicle/clients/kotlin/get-started/) # Event sources and streams > Define named event sources with streams and concurrency dimensions, register them, and append through them from Kotlin and Java. An event source definition gives an event source a stable name, an optional description, the streams it uses and the concurrency dimensions an append to it should be checked on. Definitions are optional. Appending without one behaves exactly as it always has. Routing belongs to the append, not to the event type: any event can be appended through any definition, and a definition is never attached to an event type. This needs a Chronicle 19.30.0 or later kernel. ## Define an event source Annotate a carrier class with `@EventSource`. The class is never instantiated, so it can be empty. Without an explicit name, the class name minus an `EventSource` suffix is used. ```kotlin import io.cratis.chronicle.eventSources.ConcurrencyDimension import io.cratis.chronicle.eventSources.EventSource import io.cratis.chronicle.eventSources.EventStream @EventSource( name = "Account", description = "A customer account", concurrency = [ConcurrencyDimension.EventSourceId], streams = [ EventStream( name = "Transactions", description = "Money in and out", concurrency = [ ConcurrencyDimension.EventSourceId, ConcurrencyDimension.EventStreamType ] ), EventStream("Settings") ] ) class AccountEventSource ``` ```java import io.cratis.chronicle.eventSources.ConcurrencyDimension; import io.cratis.chronicle.eventSources.EventSource; import io.cratis.chronicle.eventSources.EventStream; @EventSource( name = "Account", description = "A customer account", concurrency = ConcurrencyDimension.EventSourceId, streams = { @EventStream( name = "Transactions", description = "Money in and out", concurrency = { ConcurrencyDimension.EventSourceId, ConcurrencyDimension.EventStreamType }), @EventStream(name = "Settings") }) class AccountEventSource { } ``` Source names must be unique, and a stream name must be unique within its source. Duplicates and blank names fail with a clear exception when the definitions are discovered, before anything is sent to the kernel. A stream that declares no concurrency dimensions uses the ones of its event source. ## Register definitions Definitions are found with the other artifacts and registered at startup, right after event types, when `autoDiscoverAndRegister` is on. To register by hand, call `eventStore.eventSources.register()`. Registration is an upsert: the kernel keeps definitions the client no longer declares. `eventSources` is a member of `EventStore`. Through the `IEventStore` interface it is the extension `io.cratis.chronicle.eventSources`, which returns the registry of an event store that implements `IEventSourcesCapability` and an unsupported one otherwise. Declaring an `@EventSource` class against a store without the capability fails registration instead of silently dropping the definition. ## Append through a definition `appendThroughEventSource` stamps the event source type from the definition, checks the stream against the ones it declares, and records the event source name on the event. `appendManyThroughEventSource` does the same for an atomic batch. ```kotlin import io.cratis.chronicle.eventSequences.IEventSequence import io.cratis.chronicle.eventSequences.appendThroughEventSource suspend fun deposit( sequence: IEventSequence, accountId: String, deposited: Any ) = sequence.appendThroughEventSource( AccountEventSource::class, accountId, deposited, eventStream = "Transactions" ) ``` From Java, name the definition through the options: ```java import io.cratis.chronicle.eventSequences.AppendResult; import io.cratis.chronicle.java.AppendOptionsBuilder; import io.cratis.chronicle.java.BlockingEventSequence; class Deposits { static AppendResult deposit( BlockingEventSequence sequence, String accountId, Object deposited) { var options = new AppendOptionsBuilder() .throughEventSource(AccountEventSource.class, "Transactions") .build(); return sequence.append(accountId, deposited, options); } } ``` These errors are raised before anything is sent: * `UnknownEventSource`: the class is not a discovered definition. * `EventStreamDoesNotBelongToEventSource`: the stream is not declared by the definition. * `EventRoutingContradictsEventSource`: an explicit `eventSourceType` or `eventStreamType` disagrees with the definition. A batch is validated as a whole, so one bad event fails the batch and nothing is appended. ## Route each event of a batch `EventForEventSourceId` takes an `eventSource` and an `eventStream`, so one atomic batch can span several definitions. A per-event definition applies to that event only, and events without one are appended as before. ```kotlin import io.cratis.chronicle.eventSequences.EventForEventSourceId import io.cratis.chronicle.eventSequences.IEventSequence suspend fun transfer( sequence: IEventSequence, from: String, to: String, withdrawn: Any, deposited: Any ) = sequence.appendMany( listOf( EventForEventSourceId( from, withdrawn, eventSource = AccountEventSource::class, eventStream = "Transactions" ), EventForEventSourceId( to, deposited, eventSource = AccountEventSource::class, eventStream = "Transactions" ) ) ) ``` ## Concurrency When you do not pass a `concurrencyScope`, the definition’s dimensions (the stream’s, or the event source’s when the stream has none) are turned into a scope from the current tail of the matching events. A dimension is scoped only when its value is known: the event source id and event source type always are, a stream type or id only when one is in play. When the definition declares no dimensions, the append stays unchecked, exactly like an append without a definition. An explicit `concurrencyScope` always wins, in single appends and for any event source id in a batch’s `concurrencyScopes` map. In a batch the kernel takes one scope per event source id. The client derives the guard for every event from its own definition and stream, never from the first event that mentions the id. Events that derive no guard never suppress one that does, in any order. Guarded events that select on the same event source, stream type, stream id and source type share the first guard taken, whatever the current tail is. If they select differently, for example one through the `Transactions` stream and one through the event source alone, the batch is rejected with `ConflictingEventSourceConcurrency` before anything is appended, because applying either guard would guard the other event wrongly. Pass an explicit scope for that id, or append the events in separate batches. ## Read the event source name Events appended through a definition carry its name in `EventContext.eventSource` — when read back and when delivered to reactors and reducers. Events appended without one, including events stored before event sources existed, have an empty `eventSource`; the client does not make up a name. ## Compatibility Event sources are an additive, optional feature. Code that does not use them behaves as before, and they need a kernel at 19.30.0 or later. * **Custom `IEventStore` and `IClientArtifacts` implementations** keep compiling and linking, in Kotlin and Java, compiled or not. Neither interface gained a member: the registry is the optional `IEventSourcesCapability`, and the declared classes are the optional `IEventSourceArtifacts`. Only `EventStore`, the Spring `ResolvedEventStore`, `ClientArtifacts` and `KnownClientArtifacts` implement them. Read them through the `IEventStore.eventSources` and `IClientArtifacts.eventSources` extensions: a store without the capability reports an unsupported registry, an artifacts implementation without it reports no event sources. Declaring `@EventSource` classes against a store that does not support them fails at registration. * **Source and binary compatibility** is preserved for `EventContext`, `AppendOptions` and `EventForEventSourceId`. The constructors and `copy` methods from before event sources exist with their original defaults, so the synthetic default-argument constructors and `copy$default` methods that Kotlin code compiled against an earlier release calls are still generated. That code, and Java callers using the short positional forms, runs against this release without recompiling. `EventLog` and `EventSequence` keep their previous constructors. * The in-memory test sequence cannot honor a definition; appending through one fails with `UnsupportedOperationException` instead of dropping the metadata. # External Services > Register named HTTP and database external services with the Kotlin and Java client. This page shows how to register external services using the Chronicle Kotlin client. An external service is a named endpoint — HTTP or database — that Chronicle-side integrations (such as webhooks or reactor-triggered calls) can address by name instead of embedding connection details throughout your code. See [External Services](/chronicle/external-services/) for the concept this page assumes. ## Registering an HTTP endpoint `register` takes a name — also used as the service’s identifier — and a callback for configuring the endpoint: ```kotlin val token = System.getenv("PAYROLL_API_TOKEN") ?: error("Set PAYROLL_API_TOKEN to the payroll provider's API token") store.externalServices.register("payroll-provider") { builder -> builder .http("https://payroll.example.com/api") .withBearerToken(token) } ``` ## Authentication `IExternalServiceBuilder` supports the same three authentication schemes as webhooks, plus arbitrary headers: ```kotlin // Placeholders: supply real values from configuration or a secret store. builder.withBasicAuth("username", "password") builder.withBearerToken("token") builder.withOAuth("https://auth.example.com", "client-id", "client-secret") builder.withHeader("X-Custom-Header", "value") ``` ## Registering a database endpoint Use `msSql` or `postgreSql` instead of `http` to describe a database endpoint. `port` defaults to the provider’s standard port when left at `0`: ```kotlin val password = System.getenv("PAYROLL_DB_PASSWORD") ?: error("Set PAYROLL_DB_PASSWORD to the payroll database password") store.externalServices.register("payroll-database") { builder -> builder.postgreSql( host = "payroll-db.internal", database = "payroll", username = "chronicle", password = password ) } ``` Add provider-specific connection options with `withOption`: ```kotlin builder .msSql( host = "payroll-db.internal", database = "payroll", username = "chronicle", password = "secret" ) .withOption("Encrypt", "true") ``` ## Best practices * Register external services once at startup, next to your event type and observer registrations, so the set of integrations Chronicle knows about is easy to find. * Give services stable, descriptive names — they’re the identifier other Chronicle features (like webhooks) use to reference the service. * Keep credentials out of source control; load them from configuration or a secret store before passing them to the builder. # Migrate append routing > Preserve explicit legacy event routes when upgrading the Kotlin and Java client. Since version 6.0.0 (released 2026-09-17), the Kotlin and Java client no longer chooses routing defaults on your behalf. This page is for applications upgrading from 5.x or earlier. Before upgrading, decide whether new events should use the kernel’s defaults or continue the routes your application already uses. Existing stored events are not moved or rewritten. Before you deploy client 6.0.0 or later: 1. Upgrade every kernel node. The 6.0.0 release notes require 18.4.1 or later. 2. Pass all three route fields explicitly wherever you must keep the old route. 3. Review stream-filtered reads, observers and concurrency scopes. 4. Recompile every JVM consumer, including Java code. ## Check the route your application needs From client 6.0.0 on, omitted or empty routing resolves as follows. The client verifies its contract descriptor when it connects, so a kernel that cannot serve it refuses the connection before any append is sent. | Route field | Previous single/rich-batch default | Kernel-owned default | | --- | --- | --- | | `eventSourceType` | `Default` | `Default` | | `eventStreamType` | `Default` | `All` | | `eventStreamId` | The event-source id | `Default` | This is a **breaking routing change**, not just a serialization change. Review stream-filtered observers, queries, and concurrency scopes before switching. Earlier single-source `appendMany` calls also dropped supplied routing options; they now honor them for every event in the batch. A missing value (`null`) and an empty string both leave the decision to the kernel. The client never replaces an explicit nonempty route, including `Default`, `All`, or a stream id equal to the event-source id. An empty string is not a way to select a legacy route. ## Preserve the legacy route explicitly For single appends, set all three route fields to the values you intend to keep. These helpers accept your application’s annotated event object. ```kotlin import io.cratis.chronicle.eventSequences.AppendOptions import io.cratis.chronicle.eventSequences.IEventSequence suspend fun appendToLegacyRoute( sequence: IEventSequence, eventSourceId: String, event: Any ) = sequence.append( eventSourceId, event, AppendOptions( eventSourceType = "Default", eventStreamType = "Default", eventStreamId = eventSourceId ) ) ``` ```java import io.cratis.chronicle.eventSequences.AppendResult; import io.cratis.chronicle.java.AppendOptionsBuilder; import io.cratis.chronicle.java.BlockingEventSequence; class LegacyRouting { static AppendResult appendToLegacyRoute( BlockingEventSequence sequence, String eventSourceId, Object event) { var options = new AppendOptionsBuilder() .eventSourceType("Default") .eventStreamType("Default") .eventStreamId(eventSourceId) .build(); return sequence.append(eventSourceId, event, options); } } ``` Pass the same options to `appendMany(eventSourceId, events, options)` for a single-source batch. For a batch of `EventForEventSourceId` values, set `eventSourceType`, `eventStreamType`, and `eventStreamId` on **each** value; use that value’s own event-source id for the legacy stream id. Mixed explicit and omitted routes remain mixed rather than inheriting a route from another event. If your application previously passed options to single-source batches, check stored event contexts instead of assuming those options took effect. To continue a stored route, supply its actual values explicitly. ## Keep concurrency and compliance choices separate No concurrency defaults change: ordinary appends still use `ConcurrencyScope.none` when omitted; an explicitly supplied `none` or `notSet` is preserved. Rich batches still send exactly the scope-map entries you supplied. A source omitted from that map stays omitted. Routing fields are never copied into concurrency scopes automatically. Subject behavior also stays unchanged: an omitted subject falls back to the event-source id, while an explicit subject is forwarded unchanged. Occurred time, tags, causation, identity, and correlation are retained when a single-source batch uses explicit routing. ## Check how your reads narrow Reads narrow only by what you pass. A missing or empty filter means “do not narrow”, which is how the kernel reads an unspecified value - the client never substitutes a route of its own on a read, a tail lookup, or an observer tail. The consequence is worth stating plainly, because it is the easiest way to lose sight of new events after upgrading: an append that named no route is stored on stream type `All` and stream id `Default`. A read narrowed to the route this client used to choose - `eventStreamType = "Default"` - therefore returns none of those events. It is a filter doing its job, not missing data. ```kotlin import io.cratis.chronicle.eventSequences.AppendedEvent import io.cratis.chronicle.eventSequences.IEventSequence import kotlin.reflect.KClass /** Every event for the source, whatever route the kernel resolved. */ suspend fun readEveryRoute( sequence: IEventSequence, eventSourceId: String, eventTypes: List> ): List = sequence.getForEventSourceIdAndEventTypes(eventSourceId, eventTypes) /** Only the events the kernel routed by default. */ suspend fun readKernelDefaultRoute( sequence: IEventSequence, eventSourceId: String, eventTypes: List> ): List = sequence.getForEventSourceIdAndEventTypes( eventSourceId, eventTypes, eventStreamType = "All", eventStreamId = "Default" ) ``` `getForEventSourceIdAndEventTypes` also takes an `eventSourceType`, which narrows the read to one event source type. Omitting it, or passing `Default`, returns every source type. It reached nothing before Chronicle 18.5.0 — the request carried no such field, so a caller that passed it believed it had filtered and had not. ## Verify against the kernel Read newly appended events back and check their route and metadata using `getForEventSourceIdAndEventTypes` or `getFromSequenceNumber`. These reads carry stored routing rather than guessing it from the append request. `EventContext` now carries the compliance `subject` the kernel stored, which changes its constructor and copy signatures. Recompile JVM consumers, including Java callers, when upgrading. # Migrate to Fundamentals > Update concepts and correlation ids for Chronicle 7.x in Kotlin and Java without changing stored JSON or schemas. Chronicle 7.x adopts `io.cratis:fundamentals:0.1.0`. This is a **major, breaking release**: recompile consumers after updating. There is no dependency to add; Fundamentals comes transitively with `io.cratis:chronicle`. The JSON wire format and event schemas do not change. Concepts still serialize as their wrapped scalar value, so existing events continue to read back. ## Kotlin concepts Before: ```kotlin import io.cratis.chronicle.concepts.ConceptAs data class BookId(override val value: String) : ConceptAs ``` After: ```kotlin import io.cratis.fundamentals.concepts.ConceptAs data class BookId(val value: String) : ConceptAs { override fun value(): String = value } ``` Apply the same pattern to `@JvmInline value class` concepts. The backing property can also have a different name and be private; the interface only requires `fun value(): T`. Reading through a `ConceptAs` reference changes from `concept.value` to `concept.value()`. To keep property-style reads in Kotlin, import `io.cratis.fundamentals.concepts.value`. A concrete `bookId.value` still uses the data-class property and needs no extension import. `EventSourceId` keeps its name and package, `io.cratis.chronicle.concepts.EventSourceId`. Its implementers make the same property-to-function change. ## Java concepts Before: ```java import io.cratis.chronicle.concepts.ConceptAs; record BookId(String value) implements ConceptAs { @Override public String getValue() { return value; } } ``` After: ```java import io.cratis.fundamentals.concepts.ConceptAs; record BookId(String value) implements ConceptAs {} ``` Delete the old getter override: the record accessor implements the new interface directly. Change calls from `getValue()` to `value()`. Records implementing `EventSourceId` follow the same pattern. ## Correlation ids Before: ```kotlin import io.cratis.chronicle.correlation.CorrelationId val empty = CorrelationId.notSet val uuid = CorrelationId.create().value ``` After: ```kotlin import io.cratis.fundamentals.execution.CorrelationId val empty = CorrelationId.NOT_SET val uuid = CorrelationId.create().value() ``` `CorrelationId(uuid)` and `CorrelationId.create()` remain available. The new type is an ordinary class rather than a Kotlin value class, with value-based equality and hashing. Java can now call `begin(CorrelationId)`, `tryGetFor(CorrelationId)` and `getCorrelationId()` on units of work and their manager without value-class name mangling. `BlockingUnitOfWork` still exposes its correlation id as a string. ## Deprecated names For 7.x, `io.cratis.chronicle.concepts.ConceptAs` and `io.cratis.chronicle.correlation.CorrelationId` remain deprecated Kotlin typealiases, at warning level, with IDE import quick-fixes. References to the old types can still compile, but concept implementations must implement `value()` and the old `notSet` name is not available. Java cannot see Kotlin typealiases; Java imports must change to Fundamentals. ## Release notes ### Added * Java can begin, look up and inspect units of work by correlation id (part of [#151](https://github.com/Cratis/Chronicle.Kotlin/issues/151)). ### Changed * Breaking: `ConceptAs` moves to Fundamentals and requires `fun value(): T` instead of a property. Java records no longer need an extra getter. * Breaking: `CorrelationId` moves to Fundamentals as an ordinary class; use `NOT_SET` and `value()`. * Concept JSON and event schemas remain unchanged. ### Deprecated * The two old Chronicle type names remain as Kotlin typealiases for this major version; migrate imports as shown above. # Projections > Where the shared guide to projections lives, with Kotlin and Java tabs. Projections are shared Chronicle read-model behavior. Use the shared docs for projection styles, model-bound projections, declarative projections, and client-tabbed examples. * [Projections](/chronicle/projections/) * [Choosing a read model style](/chronicle/projections/choosing-a-read-model-style/) * [Model-bound projections](/chronicle/projections/model-bound/) * [Declarative projections](/chronicle/projections/declarative/) * [Kotlin and Java client setup](/chronicle/clients/kotlin/get-started/) ## Kotlin model-bound attributes Beyond `@FromEvent` and `@SetFrom`, the Kotlin client has annotations for structural shapes (joins, children, nested objects), arithmetic (counters, running totals), catch-all mappings, and rewind behavior. Full parameter tables are in the [annotation reference](/chronicle/clients/kotlin/reference/annotations/). | Attribute | Use it for | | --- | --- | | `@Join` | Pulling in a property from another event type by id. | | `@ChildrenFrom` | A collection built from child instances of an event type. | | `@Nested` | A nullable sub-object built from its own `@FromEvent`. | | `@ClearWith` | Which event clears (nulls out) a `@Nested` property. | | `@Count` | An occurrence counter for a specific event type. | | `@Increment` / `@Decrement` | Bumping a numeric property by one. | | `@AddFrom` / `@SubtractFrom` | Adding/subtracting an event value in. | | `@FromAll` / `@FromEvery` | A property populated from every event type. | | `@NotRewindable` | Marking a projection as forward-only. | | `@RemovedWith` | Which event removes an instance or a child. | | `@RemovedWithJoin` | Like `@RemovedWith`, but resolving the id via a join. | | `@NoAutoMap` | Disabling AutoMap for a type or a single property. | `@NotRewindable` is worth considering for projections fed by events that can’t reliably be redelivered — e.g. from an [event store subscription](/chronicle/subscriptions/explicit-subscriptions/). ```kotlin import io.cratis.chronicle.projections.Increment import io.cratis.chronicle.projections.Join import io.cratis.chronicle.readModels.ReadModel @ReadModel data class OrderOverview( val id: String = "", @Increment(OrderShipped::class) val shipmentCount: Int = 0, @Join( eventType = CustomerRegistered::class, on = "customerId", eventPropertyName = "email" ) val customerEmail: String = "" ) ``` ## Richer declarative projections `IProjectionBuilderFor` also supports `.join()`, `.fromEvery()`/`.fromAll()`, `.removedWith()`/`.removedWithJoin()`, `.children()`, `.nested()`, and `.notRewindable()` — the fluent equivalents of the attributes above, for projections defined with a separate `IProjectionFor` class instead of model-bound annotations: ```kotlin import io.cratis.chronicle.projections.IProjectionBuilderFor import io.cratis.chronicle.projections.IProjectionFor class OrderOverviewProjection : IProjectionFor { override fun define(builder: IProjectionBuilderFor) { builder .from(OrderPlaced::class) .join(CustomerRegistered::class) { join -> join.on(OrderOverview::customerEmail) .set(OrderOverview::customerEmail) .toProperty("email") } .notRewindable() } } ``` # Reactors > Where the shared guide to reactors lives, with Kotlin and Java tabs. Reactors are documented in the shared Chronicle docs with synchronized examples for C#, Kotlin, Java, Elixir, and TypeScript. * [Getting started with reactors](/chronicle/reactors/getting-started/) * [Reactor event processing](/chronicle/reactors/event-processing/) * [Reactors overview](/chronicle/reactors/) Use the [Kotlin annotation reference](/chronicle/clients/kotlin/reference/annotations/) for JVM annotation details. # Read Model Reactors > React to read model changes in Kotlin and Java with a read model reactor instead of collecting watch flows by hand. `store.readModels.watch(...)` hands you a `Flow` of changesets — and with it the job of collecting it, branching on the change type, keeping the subscription alive, and doing it again for every read model you care about. A read model reactor takes that plumbing away. You write one method per change, register the class, and Chronicle dispatches to it. Where a [reactor](/chronicle/clients/kotlin/guides/reactors/) reacts to events, a read model reactor reacts to the *state those events produced* — an instance being added, modified, or removed. ## Writing a reactor Implement `IReadModelReactor` and name a method after the change it handles: `added`, `modified` or `removed`. The first parameter is the read model, and its type is what decides which read model the reactor watches — the same way a reactor’s first parameter selects its event type. ```kotlin import io.cratis.chronicle.readModels.IReadModelReactor import io.cratis.chronicle.readModels.ReadModelChangeset class EmployeeProfileAlerts : IReadModelReactor { fun added(profile: EmployeeProfile) { println("${profile.firstName} joined ${profile.department}") } fun modified( profile: EmployeeProfile, changeset: ReadModelChangeset ) { println("${changeset.modelKey} changed at " + "sequence ${changeset.eventSequenceNumber}") } } ``` The optional second parameter is the `ReadModelChangeset` that caused the call. Take it when you need the instance’s `modelKey`, the `eventSequenceNumber` that produced the change, or its `correlationId`; leave it off when the instance itself is all you need. Method names are matched case-insensitively, so `Added` works as well as `added`. ## Reacting to removals A removal never carries an instance — there is nothing left to hand you — so a Kotlin `removed` handler must declare its read model parameter nullable. The key of the instance that went away is on the changeset: ```kotlin class EmployeeProfileOffboarding : IReadModelReactor { fun removed( profile: EmployeeProfile?, changeset: ReadModelChangeset ) { println("${changeset.modelKey} is gone") } } ``` Declaring it non-nullable fails at registration with `InvalidHandlerSignature` rather than throwing on every removal that arrives. ## Watching more than one read model Each handler’s first parameter stands on its own, so one reactor can cover several read models. Chronicle opens one watch per read model it finds: ```kotlin class HrDashboardAlerts : IReadModelReactor { fun added(profile: EmployeeProfile) { println("profile added: ${profile.id}") } fun added(overview: OrderOverview) { println("order added: ${overview.id}") } } ``` A handler can also take a `List` of read models instead of a single instance; the element type is then what selects the read model. ## Returning events as side effects Like a reactor, a handler may return an event instead of taking a dependency on the event log. It is appended for you, using the changed instance’s key as the event source id: ```kotlin import io.cratis.chronicle.events.EventType @EventType data class EmployeeProfileArchived(val employeeId: String) class EmployeeProfileArchiver : IReadModelReactor { fun removed( profile: EmployeeProfile?, changeset: ReadModelChangeset ) = EmployeeProfileArchived(changeset.modelKey) } ``` Return a `List` to append several events, and wrap one in `EventForEventSourceId` to send it to an event source other than the changed instance’s key. Anything that is not an event type — including `Unit` — is ignored, so handlers that just do their work and return nothing are fine. ## Registering and stopping `ReadModelReactors` needs the read models to watch through and the event log to append side effects to. Registration is not a suspending call: it starts the subscriptions and returns the `Job` backing them. ```kotlin import io.cratis.chronicle.readModels.ReadModelReactors val readModelReactors = ReadModelReactors(store.readModels, store.eventLog) readModelReactors.register(EmployeeProfileAlerts()) readModelReactors.register(EmployeeProfileOffboarding()) // Later, when shutting down: readModelReactors.stop() ``` `stop()` cancels every reactor registered through that instance. Cancel the returned `Job` instead when you want to stop a single reactor. Handlers may be called more than once for the same change — a watch that ends is re-established — so keep them idempotent, exactly as you would a reactor. ## Java `IReadModelReactor` is a plain marker interface and `register` is not a suspending function, so Java uses both directly with no bridge. Java parameters carry no nullability, so a Java `removed` handler declares the read model type as it is: ```java import io.cratis.chronicle.readModels.IReadModelReactor; import io.cratis.chronicle.readModels.ReadModelChangeset; public class JavaEmployeeProfileAlerts implements IReadModelReactor { public void added(EmployeeProfile profile) { System.out.println(profile.getFirstName() + " joined"); } public void removed( EmployeeProfile profile, ReadModelChangeset changeset) { System.out.println(changeset.getModelKey() + " is gone"); } } ``` ```java import io.cratis.chronicle.readModels.ReadModelReactors; ReadModelReactors readModelReactors = new ReadModelReactors(store.getReadModels(), store.getEventLog()); readModelReactors.register(new JavaEmployeeProfileAlerts()); ``` See the [EventStore API reference](/chronicle/clients/kotlin/reference/event-store-api/) for the full `IReadModelReactors` surface, and [Read Models](/chronicle/clients/kotlin/concepts/read-models/) for the imperative `watch` this builds on. # Reducers > Where the shared guide to reducers lives, with Kotlin and Java tabs. Reducers are shared Chronicle read-model behavior. Use the shared reducer docs for the model, lifecycle, filtering, and client-tabbed examples. * [Reducers](/chronicle/reducers/) * [Getting started with reducers](/chronicle/reducers/getting-started/) * [Reducer event processing](/chronicle/reducers/event-processing/) * [Kotlin and Java client setup](/chronicle/clients/kotlin/get-started/) # Seeding > Seed events from Kotlin and Java so an event store starts with the facts it needs, per namespace. This page shows how to seed events using the Chronicle Kotlin client. Seeding is sent to the Chronicle Server when the event store connects, and the server applies it once per namespace. See [Event Seeding](/chronicle/event-seeding/) for the concept this page assumes. ## Define events ```kotlin import io.cratis.chronicle.events.EventType @EventType data class AccountOpened( val accountId: String, val ownerName: String, val initialBalance: Double ) @EventType data class FundsDeposited(val accountId: String, val amount: Double) ``` ## Implement a seeder Annotate a class with `@Seeder`, implement `ICanSeedEvents`, and use `IEventSeedingBuilder.forEventSource` to accumulate events: ```kotlin import io.cratis.chronicle.seeding.ICanSeedEvents import io.cratis.chronicle.seeding.IEventSeedingBuilder import io.cratis.chronicle.seeding.Seeder @Seeder class AccountSeeder : ICanSeedEvents { override fun seed(builder: IEventSeedingBuilder) { builder.forEventSource( "account-1", listOf(AccountOpened("account-1", "Alice", 1000.0)) ) } } ``` ## Seed mixed event types for one event source `forEventSource` takes a list of any event types, so mixing types for the same event source is the same call: ```kotlin @Seeder class MixedAccountSeeder : ICanSeedEvents { override fun seed(builder: IEventSeedingBuilder) { builder.forEventSource( "account-1", listOf( AccountOpened("account-1", "Alice", 1000.0), FundsDeposited("account-1", 500.0) ) ) } } ``` Chain multiple calls to seed several event sources: ```kotlin @Seeder class MultiAccountSeeder : ICanSeedEvents { override fun seed(builder: IEventSeedingBuilder) { builder .forEventSource( "account-1", listOf(AccountOpened("account-1", "Alice", 1000.0)) ) .forEventSource( "account-2", listOf(AccountOpened("account-2", "Bob", 500.0)) ) } } ``` ## Seed a specific event type `forEventType` is an alternative to `forEventSource` that also checks the event class is annotated with `@EventType` before accepting it — useful when a seeder should only ever seed one specific event type and you want that caught early rather than at the server: ```kotlin @Seeder class TypedAccountSeeder : ICanSeedEvents { override fun seed(builder: IEventSeedingBuilder) { builder.forEventType( AccountOpened::class, "account-1", listOf(AccountOpened("account-1", "Alice", 1000.0)) ) } } ``` ## Seed a specific namespace By default, seed data targets the event store’s own namespace. Call `forNamespace` to target a different one instead — it returns a scoped builder whose `forEventSource`/`forEventType` calls all seed into that namespace, leaving calls made directly on `builder` targeting the event store’s own namespace as before: ```kotlin @Seeder class MultiNamespaceAccountSeeder : ICanSeedEvents { override fun seed(builder: IEventSeedingBuilder) { builder .forEventSource( "account-1", listOf(AccountOpened("account-1", "Alice", 1000.0)) ) .forNamespace("staging") .forEventSource( "account-1", listOf(AccountOpened("account-1", "Staging Alice", 250.0)) ) } } ``` ## Running seeders Pass seeder instances to the event store’s `seeding` service: ```kotlin import io.cratis.chronicle.IEventStore suspend fun runSeeders(store: IEventStore) { store.seeding.seed(AccountSeeder()) } ``` ## How it runs * Seed batches are sent to the Chronicle Server when the event store connects. * The server deduplicates seeded events and applies them once per namespace. * Events are appended in a single batch for efficient startup. ## Best practices * Keep seed data minimal and deterministic. * Use clear event source IDs to make debugging easier. * Group seeders by scenario so you can remove or adjust them easily. * Only call `store.seeding.seed(...)` when you want seeding to run — for example, guard it behind a development-only build flag or configuration check. # Spring Boot > Add the Chronicle Spring Boot starter to a Kotlin or Java application, inject IEventStore or Chronicle, and use per-request tenancy, identity, causation and units of work. The Chronicle Spring Boot starter turns “wire up an event-sourced backend” into “add a dependency”. Put it on the classpath and you get a connected client, every artifact in your application registered with the kernel at startup, per-request tenancy, identity and units of work, and an `IEventStore` you can inject anywhere. It works the same from Kotlin and from Java. It targets Spring Boot 4 and the servlet stack (Spring MVC); the per-request features do not apply to WebFlux. ## Add the dependency The starter is published to Maven Central as `io.cratis:chronicle-spring-boot-starter`, and brings the client with it. It is built against Spring Boot 4.1.1. This page was checked with `6.5.0`. build.gradle.kts ```kotlin dependencies { implementation("io.cratis:chronicle-spring-boot-starter:6.5.0") } ``` build.gradle ```groovy dependencies { implementation 'io.cratis:chronicle-spring-boot-starter:6.5.0' } ``` With Maven: ```xml io.cratis chronicle-spring-boot-starter 6.5.0 ``` Upgrading from 6.4.0 or earlier Versions up to 6.4.0 needed `kotlinx-coroutines` 1.11, above the 1.10.2 that Spring Boot 4.1 manages, and failed at startup with `NoSuchMethodError: 'java.lang.Object kotlinx.coroutines.BuildersKt.runBlockingK(…)'` unless the application set Spring Boot’s `kotlin-coroutines.version` property to `1.11.0`. From 6.5.0 the starter runs on the managed version, so you can remove that override. ## Configure it Everything has a default that works on a developer’s machine, so the only setting most applications need is the name of their event store: ```yaml cratis: chronicle: event-store: Ordering ``` That gives you the local development kernel on `localhost:35000`, the `Default` namespace, and automatic discovery and registration of every artifact in your application’s packages. For any other kernel, set `cratis.chronicle.connection-string`, with `?skipTlsValidation=false` and the secret supplied from the environment (for example through `CRATIS_CHRONICLE_CONNECTION_STRING`); see [Configuration](/chronicle/clients/kotlin/reference/configuration/#tls-and-authentication). ## Write artifacts, not wiring There is no registration code. Write the event, the read model, and the reducer, and they are found and registered on startup: ```kotlin @EventType data class EmployeeHired( val firstName: String = "", val lastName: String = "", val title: String = "" ) @ReadModel data class EmployeeState( val id: String = "", val firstName: String = "", val title: String = "" ) @Reducer class EmployeeStateReducer { fun employeeHired(event: EmployeeHired): EmployeeState = EmployeeState(firstName = event.firstName, title = event.title) } ``` Discovery is scoped to the packages Spring Boot already scans for components — the package of your `@SpringBootApplication` class and everything beneath it. Override it with `cratis.chronicle.artifact-packages` when your artifacts live somewhere else. ## Artifacts are Spring components An artifact is activated through the container, so it takes its dependencies through its constructor exactly like a `@Service` would: ```kotlin @Reactor class WelcomePackageReactor(private val mailer: Mailer) { fun employeeHired( event: EmployeeHired, context: EventContext ): WelcomePackageRequested { val address = "${event.firstName}.${event.lastName}@example.com" mailer.send(address, "Welcome to the team!") return WelcomePackageRequested(context.eventSourceId) } } ``` ```java @Reactor public class WelcomePackageReactor { private final Mailer mailer; public WelcomePackageReactor(Mailer mailer) { this.mailer = mailer; } public WelcomePackageRequested employeeHired( EmployeeHired event, EventContext context) { mailer.send(event.firstName() + "." + event.lastName() + "@example.com", "Welcome to the team!"); return new WelcomePackageRequested(context.getEventSourceId()); } } ``` Declaring an artifact as a `@Component` is optional. If you do, that bean is the one used, with its own scope and lifecycle. If you do not, it is still constructed with everything it needs injected. ## Using it from Kotlin Inject `IEventStore` and use the full coroutine API. Spring MVC handlers run on a request thread, so bridge with `runBlocking`: ```kotlin @RestController @RequestMapping("/api/employees") class Employees(private val eventStore: IEventStore) { @PostMapping("/{id}/hire") fun hire(@PathVariable id: String, @RequestBody hire: Hire) = runBlocking { eventStore.eventLog.append( id, EmployeeHired(hire.firstName, hire.lastName, hire.title)) ResponseEntity.accepted().build() } @GetMapping("/{id}") fun get(@PathVariable id: String) = runBlocking { eventStore.readModels.getInstanceByKey(EmployeeState::class, id) ?.let { ResponseEntity.ok(it) } ?: ResponseEntity.notFound().build() } } ``` Keep the `runBlocking` bridge on the request thread rather than making the handler `suspend`. The current identity, causation chain, namespace and unit of work are held per thread, and `runBlocking` keeps your code on the thread the request filters set them on. The read model is updated after the append, not during it, so a `GET` issued right after the `POST` can still find nothing. See [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/#reading-after-writing). ## Using it from Java Java has no coroutines, so inject `Chronicle` instead. It is the same event store, with the everyday operations exposed as ordinary blocking methods: ```java @RestController @RequestMapping("/api/employees") public class Employees { private final Chronicle chronicle; public Employees(Chronicle chronicle) { this.chronicle = chronicle; } @PostMapping("/{id}/hire") public ResponseEntity hire( @PathVariable String id, @RequestBody Hire hire) { chronicle.append(id, new EmployeeHired(hire.firstName(), hire.lastName(), hire.title())); return ResponseEntity.accepted().build(); } @GetMapping("/{id}") public EmployeeState get(@PathVariable String id) { return chronicle.readModel(EmployeeState.class, id); } } ``` `readModel` returns `null` when there is no instance yet, which includes the moment right after the append that creates it. Anything beyond the everyday is one hop away through `chronicle.getEventStore()`, which is the full API. | Method on `Chronicle` | Description | | --- | --- | | `append(eventSourceId, event)` | Append one event | | `appendMany(eventSourceId, events)` | Append several to one event source | | `readModel(type, key)` | One read model instance by key | | `readModels(type)` | Every instance of a read model | | `readModelHistory(type, key)` | Every state an instance has been through | | `inUnitOfWork(work)` | Run work in a unit of work, then commit it | | `getEventStore()` | The full `IEventStore` | ## What you get per request In a servlet application the starter adds three filters. Each can be turned off on its own. ### Identity The authenticated principal becomes the identity recorded on every event appended while handling the request, so the audit trail comes out right without any code passing a user around. Subject, name and username are read from the `sub`, `name` and `preferred_username` claims, falling back to the principal’s name. Requires Spring Security on the classpath. Disable with `cratis.chronicle.identity.enabled: false`. ### Causation The route, method, host, scheme and query of the request are recorded on the causation chain of every event it produces. “Why does this record say what it says?” is then answered by the event’s own metadata rather than by correlating log files. Disable with `cratis.chronicle.causation.enabled: false`. ### Unit of work Each request runs inside a unit of work that is committed when the request completes and rolled back if it throws. Only appends made through `eventLog.transactional` are staged in it. A plain `eventLog.append`, and `Chronicle.append` from Java, go to the kernel straight away and are not undone when the request fails. Consecutive staged events for the same event source, event sequence and append options are appended as one atomic batch: either all of them land or none do. Anything else starts a new batch, including a different event source, and the batches are sent one after another. One batch can land while a later one is rejected. The filter commits after your handler has returned, which is too late to tell the caller about a rejected append. When the response depends on the outcome, commit in the handler: ```kotlin @PostMapping("/{id}/hire") fun hire(@PathVariable id: String, @RequestBody hire: Hire) = runBlocking { // Staged in the request's unit of work; nothing is sent yet. eventStore.eventLog.transactional.append( id, EmployeeHired(hire.firstName, hire.lastName, hire.title)) eventStore.eventLog.transactional.append(id, EmployeeEmailSet(hire.email)) // Consecutive events for the same event source with the same options go in // one batch. If the email is already taken, the constraint stops both. val unitOfWork = eventStore.unitOfWorkManager.current unitOfWork.commit() if (unitOfWork.isSuccess) { ResponseEntity.accepted().build() } else { ResponseEntity.status(HttpStatus.CONFLICT) .body(unitOfWork.getConstraintViolations().map { it.message }) } } ``` While a unit of work is active, `transactional.append` returns a placeholder result with sequence number `-1` and `isSuccess` set to `true`; the real outcome is on the unit of work after `commit()`. A handler that commits or rolls back itself is left alone by the filter. Disable the filter with `cratis.chronicle.unit-of-work.enabled: false`. ## Multi-tenancy A namespace is Chronicle’s tenancy boundary: the same event store, the same artifacts, entirely separate streams of events. The injected `IEventStore` routes to the namespace the current piece of work belongs to on every call, so application code never mentions a tenant. Pick how the tenant is decided: ```yaml cratis: chronicle: namespace-resolution: strategy: http-header # fixed | http-header | subdomain | authentication http-header: x-cratis-tenant-id claim: tenant_id ``` | Strategy | Where the namespace comes from | | --- | --- | | `fixed` | `cratis.chronicle.namespace`. The single-tenant default | | `http-header` | A header on the current request | | `subdomain` | The first label of the host — `acme` in `acme.example.com` | | `authentication` | A claim on the authenticated principal | `authentication` is the strongest of the four, because the tenant comes from the token rather than from anything a caller can set. It still falls back to `Default` when a request is not authenticated or the token has no tenant claim, so require authentication and reject requests without a valid tenant claim before they reach Chronicle. Work outside a request, such as a scheduled job or a reactor, falls back to the `Default` namespace. A header or subdomain is chosen by the caller With `http-header` or `subdomain`, whoever sends the request picks the namespace, and a request without the header or a subdomain lands in `Default`. Use them for tenant isolation only behind something that checks the caller belongs to that tenant, or use `authentication`. Declare your own `IEventStoreNamespaceResolver` bean to decide it any other way: ```kotlin @Bean fun namespaceResolver(tenants: TenantDirectory) = IEventStoreNamespaceResolver { tenants.currentTenant().namespace } ``` ## Startup The client is created while the application context starts, and it connects right away. If the kernel is unreachable at that moment, or incompatible, the `chronicleClient` bean fails and the application does not start (`APPLICATION FAILED TO START`, caused by `io.grpc.StatusException: UNAVAILABLE`). Once the application is ready, the starter waits for the first registration pass, for at most `cratis.chronicle.registration-timeout` (30 seconds by default), and logs `Chronicle artifacts registered with event store '…'`. By then the web server is already accepting requests. That is safe for appends: the first append waits for the same registration pass. If the pass has not finished in time, the starter logs a warning and carries on, and the client keeps trying to connect in the background; requests that append wait until it succeeds, so treat that warning as an outage. When the kernel rejects the connection, because the credentials are wrong or the server is incompatible, the wait fails with `ChronicleConnectionFailed` instead, and the application stops with `Application run failed`. See [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/) for the underlying behavior. ## Configuration reference | Property | Default | Description | | --- | --- | --- | | `connection-string` | development kernel | Where the kernel is | | `event-store` | `Default` | The event store to work against | | `namespace` | `Default` | Namespace for the `fixed` strategy | | `auto-discover-and-register` | `true` | Register artifacts on connect | | `artifact-packages` | application packages | Packages to scan | | `default-sink-type-id` | `MongoDB` | Where read models are persisted | | `program-identifier` | `spring.application.name` | Name in diagnostics | | `registration-timeout` | `30s` | How long startup waits for registration | | `namespace-resolution.strategy` | `fixed` | How the namespace is decided | | `namespace-resolution.http-header` | `x-cratis-tenant-id` | Header to read | | `namespace-resolution.claim` | `tenant_id` | Claim to read | | `unit-of-work.enabled` | `true` | Unit of work per request | | `causation.enabled` | `true` | Request causation on events | | `identity.enabled` | `true` | Authenticated user as event identity | ## Replacing what the starter provides Every bean the starter contributes backs off the moment your application declares its own, so nothing has to be turned off before it can be replaced — declare a `ChronicleOptions`, `IChronicleClient`, `IEventStore`, `IArtifactActivator` or `IEventStoreNamespaceResolver` bean and yours wins. The starter also picks up a `ReadModelNamingPolicy` bean when exactly one exists (or one is marked primary), and gives it to the `ChronicleOptions` it builds. The policy decides the name of the collection or table each read model is stored in, so a layer that reads by its own naming convention, such as Arc, finds what the client wrote once its policy is adapted into this bean. Without a bean the read model identifier is used. See [Read model container names](/chronicle/clients/kotlin/reference/configuration/#read-model-container-names). ```kotlin @Bean fun readModelNamingPolicy(): ReadModelNamingPolicy = ReadModelNamingPolicy { readModelClass -> "${readModelClass.simpleName}s" } ``` ## Samples * `Samples/Kotlin/SpringBoot` — the Kotlin version of everything above * `Samples/Java/SpringBoot` — the same application in Java Both need a running kernel. From the repository root: ```bash docker run --rm -p 127.0.0.1:35000:35000 cratis/chronicle:latest-development ./gradlew :Samples:Kotlin:SpringBoot:bootRun ``` ## See also * [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/): what gets discovered, and how to narrow, replace or turn off discovery * [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/): connecting, reconnecting, and where failures show up # Testing > Specify what a Kotlin slice appends and what a reducer folds, in-process with io.cratis:chronicle-testing and no kernel. Specifying a Chronicle-backed slice used to mean driving the real client against a running event store: a kernel, a sink, Docker, and a test that takes seconds instead of milliseconds. That is an integration test, and it has its place — but it is a poor place to specify “given these three events, the state should be this”. `io.cratis:chronicle-testing` runs the same code with nothing behind it. The scenario API is Kotlin: `given` and `fold` are `suspend` functions, so a Java test cannot call them directly. The examples on this page are Kotlin. ## Adding it Use the same version as the client; it is released alongside it. ```kotlin dependencies { testImplementation("io.cratis:chronicle-testing:6.5.0") } ``` The assertions throw `AssertionError`, so they work with JUnit or any other test runner. The examples below use JUnit 5 and `runBlocking`, because the scenario methods suspend. ## Specifying what gets appended `EventScenario` hands your code an `IEventSequence` that keeps events in a list, then lets you ask what landed in it: ```kotlin import io.cratis.chronicle.testing.EventScenario import io.cratis.chronicle.testing.shouldHaveAppended import io.cratis.chronicle.testing.shouldHaveAppendedExactly @Test fun `registering an employee records that they were hired`() = runBlocking { val scenario = EventScenario() Registrations(scenario.eventLog) .register("employee-1", "Ada", "Lovelace") scenario.shouldHaveAppended("employee-1") { it.firstName == "Ada" } scenario.shouldHaveAppendedExactly(1) } ``` `given` appends preconditions — what was already true before the code under test ran — so an assertion about what it appended does not have to count them: ```kotlin scenario.given( "employee-1", EmployeeHired("Ada", "Lovelace", "Engineer") ) Registrations(scenario.eventLog).promote("employee-1", "Principal") scenario.shouldHaveAppended("employee-1") ``` | Assertion | Fails unless | | --- | --- | | `shouldHaveAppended(source) { }` | One `T` matching the condition landed | | `shouldHaveAppendedExactly(count)` | Exactly `count` events landed in total | | `shouldHaveAppendedExactly(count)` | Exactly `count` events of `T` landed | | `shouldNotHaveAppended()` | No `T` was appended | | `shouldHaveAppendedNothing()` | Nothing at all was appended | The assertions exist for their failure messages rather than for the check — a failure prints every event that *was* appended, with its event source and its JSON, which is usually enough to see the problem without a debugger. ## Specifying what a reducer folds A reducer is a fold: events in, read model out. `ReadModelScenario` runs it the way the client runs it against a real kernel — handlers discovered the same way, chosen by event type the same way, invoked with the same shapes, awaited if they suspend: ```kotlin import io.cratis.chronicle.testing.ReadModelScenario @Test fun `promotion changes the title and keeps the name`() = runBlocking { val scenario = ReadModelScenario(EmployeeStateReducer()) val state = scenario.fold( "employee-1", EmployeeHired("Ada", "Lovelace", "Engineer"), EmployeePromoted("Principal Engineer") ) assertEquals("Ada", state!!.firstName) assertEquals("Principal Engineer", state.title) } ``` Chronicle folds each event source independently, and so does this — state does not leak between event sources, which is exactly the mistake a reducer spec should be able to catch. `stateFor(eventSourceId)` reads back what each one folded to, and folding the same event source again continues from where it left off, so a history can be built in stages. ## What is real and what is not Real: * The client’s own serializer. Every event round-trips through it on the way in, so an event that could not survive a trip to the kernel fails here too — and so does a reducer that only works on an instance it was handed directly. * Event contexts. Sequence numbers, event source ids, stream type and id, tags and the time it occurred are filled in the way the kernel fills them. * Handler dispatch. The same discovery, the same shapes, the same `suspend` support. Not real, and deliberately so: * **Constraints.** Nothing is enforced, so every append succeeds. A constraint is the kernel’s job and belongs in an integration test. * **Projections.** Only reducers fold in-process. A projection is a declaration the kernel executes. * **Concurrency scopes.** Accepted and ignored. * **Observation.** Nothing observes, so reactors do not run and there is no replay. When a spec needs any of those, it needs a kernel — and at that point it is an integration test, which is a different and more expensive thing on purpose. # Tracing > Trace Chronicle appends and observer handling from the JVM client with OpenTelemetry, and the span names and attributes it reports. A request comes in, a command appends an event, a reactor picks it up and calls something else. When that takes eight seconds, the question is which part of it did — and without Chronicle in the trace, the answer is a gap. The client reports spans through the OpenTelemetry API, so Chronicle work nests under whatever span your application was already in. ## Turning it on Nothing, if your application already runs the OpenTelemetry SDK. The client depends on the API alone, which no-ops until an SDK is registered, so: * an application that never instruments pays a virtual call and nothing else * an application that does gets Chronicle spans without configuring the client If your application holds its own `OpenTelemetry` rather than registering it globally, hand it over: ```kotlin val options = ChronicleOptions.development().copy(openTelemetry = myOpenTelemetry) ``` ## What you get | Span | Produced when | | --- | --- | | `Chronicle append ` | An event is appended | | `Chronicle appendMany` | A batch is appended | | `Chronicle observe ` | A reactor handles an event | | `Chronicle reduce ` | A reducer folds an event | Every span is reported under the `io.cratis.chronicle` instrumentation scope, and carries the identifiers you would otherwise correlate by hand: | Attribute | On | | --- | --- | | `chronicle.event_type` | Appends and observations | | `chronicle.event_source_id` | Appends and observations | | `chronicle.event_sequence_id` | All | | `chronicle.event_store` | Appends | | `chronicle.namespace` | Appends | | `chronicle.observer_id` | Observations | | `chronicle.sequence_number` | Observations | | `chronicle.event_count` | Batch appends | | `chronicle.is_replay` | Observations | An append the kernel refuses, or a handler that throws, ends its span with an error status and the exception recorded on it — which is usually the span you came looking for. ## Reading a trace An observation span is not a child of the append that produced the event. They are separate pieces of work, often in separate processes, connected by the correlation id rather than by span parentage. Query on `chronicle.event_source_id` to see everything that happened to one event source. Inside a reactor, anything else instrumented — an outgoing HTTP call, a database query — nests under the observation span, because the span is made current for the duration of the handler. ## Adding your own `IReactorMiddleware` wraps every reactor handler invocation, which is where application-specific instrumentation belongs rather than inside the reactors. See [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/#reactor-middlewares). # Transactions > Where the shared guide to transactions and units of work lives, with Kotlin and Java tabs. Unit-of-work transactions are now documented as a shared Chronicle workflow with synchronized Kotlin and Java examples. * [Transactions and unit of work](/chronicle/events/transactions/) * [Appending many events](/chronicle/events/appending-many/) * [Kotlin and Java client setup](/chronicle/clients/kotlin/get-started/) ## Kotlin client: `IUnitOfWork` Beyond `commit`/`rollback`, `IUnitOfWork` (from `store.unitOfWorkManager`) exposes the outcome of a commit: | Member | Use it for | | --- | --- | | `isSuccess` | Whether every staged event committed cleanly. | | `getConstraintViolations` | Constraint violations detected on commit. | | `getConcurrencyViolations` | Concurrency violations detected on commit. | | `getAppendErrors` | Append errors detected on commit. | | `tryGetLastCommittedEventSequenceNumber` | Highest sequence committed. | | `onCompleted` | A callback invoked once commit or rollback completes. | See the [EventStore API reference](/chronicle/clients/kotlin/reference/event-store-api/#unit-of-work) for the full `IUnitOfWork`/`IUnitOfWorkManager` interfaces. ```kotlin val unitOfWork = store.unitOfWorkManager.begin() unitOfWork.onCompleted { completed -> println("Unit of work ${completed.correlationId} completed") } ``` # Reference > Look up the JVM client's annotations, IEventStore API, configuration, connection lifecycle and geospatial types. Complete API and annotation reference for the Chronicle client for Kotlin and Java. * [Annotations](/chronicle/clients/kotlin/reference/annotations/): every annotation and its parameters * [EventStore API](/chronicle/clients/kotlin/reference/event-store-api/): `IEventStore`, `IEventLog`, the service interfaces, and the Java blocking API * [Configuration](/chronicle/clients/kotlin/reference/configuration/): `ChronicleOptions`, connection strings, TLS and authentication * [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/): connecting, compatibility checks, reconnecting, registration, and where failures show up * [Geospatial types](/chronicle/clients/kotlin/reference/geospatial/): `Point`, `LineString`, `Polygon` as GeoJSON # Annotations > Every annotation in the Chronicle JVM client, with its parameters, defaults and where it applies. ## @EventType Marks a data class as a Chronicle event type. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `id` | `String` | `""` | Stable identifier. Defaults to class name. | | `generation` | `Int` | `1` | Schema version. Increment when shape changes. | | `tombstone` | `Boolean` | `false` | Signals event source retirement. | ```kotlin import io.cratis.chronicle.events.EventType @EventType data class OrderPlaced(val orderId: String, val totalAmount: Double) ``` Omitting `id` is the common case — Chronicle uses `OrderPlaced` as the identifier automatically. *** ## @Reactor Marks a class as a Chronicle reactor. Each public method becomes a handler for the event type of its first parameter. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `id` | `String` | `""` | Stable identifier. Defaults to class name. | | `eventSequence` | `String` | event log | The event sequence to observe. Overridden by [@EventSequence](#eventsequence). | A handler takes the event, and optionally an `EventContext` carrying the event’s metadata: ```kotlin import io.cratis.chronicle.events.EventContext import io.cratis.chronicle.observation.Reactor @Reactor class OrderNotifications { fun orderPlaced(event: OrderPlaced) { println("Order ${event.orderId} placed") } fun orderShipped(event: OrderShipped, context: EventContext) { println("Order ${event.orderId} shipped at ${context.occurred}") } } ``` Supply an explicit `id` only when you need the identifier to survive class renames. *** ## @OnceOnly Excludes a reactor, or a single handler, from replay. Put it on the class and the whole reactor is registered as non-replayable, so redaction, revision, and observer rewind never replay it. Put it on one method and only that handler is skipped when an event arrives as part of a replay — the reactor’s other handlers still replay. Use it for side effects where running again is worse than never running again. ```kotlin import io.cratis.chronicle.observation.OnceOnly import io.cratis.chronicle.observation.Reactor @Reactor class PaymentNotifications { @OnceOnly fun orderPlaced(event: OrderPlaced) { println("Charging for ${event.orderId} - never repeated on replay") } } ``` *** ## @StartAtTail Marks a reactor class so it does not observe history that predates its first registration. A new reactor normally catches up from the very beginning of the event sequence, so one whose handlers cause side effects acts on every historical event the moment it is deployed. [@OnceOnly](#onceonly) does not prevent that, because it only excludes replay. With `@StartAtTail`, the first time Chronicle sees the reactor it begins at the tail of the event sequence and observes only what is appended afterwards. It only affects the first registration. A reactor that already has a position keeps it, so the annotation never skips events after a restart. Renaming the reactor or moving its package changes its default id and makes it a new reactor, which starts at the tail again; give `@Reactor` an explicit `id` to keep the old position. An explicit replay still replays from the beginning. The Chronicle server must also support it: a server that predates it ignores the request and the reactor catches up from the beginning. ```kotlin import io.cratis.chronicle.observation.Reactor import io.cratis.chronicle.observation.StartAtTail @Reactor @StartAtTail class NewOrderNotifications { fun orderPlaced(event: OrderPlaced) { println("Notifying ${event.orderId} - only for new orders") } } ``` *** ## @Replay Marks a reactor handler as the one to run while events are being replayed. When an event type has a handler marked with this, it takes over for the duration of the replay and the everyday handler does not also run. Without one, the everyday handler keeps running during replay. Use [@OnceOnly](#onceonly) instead when the side effect should simply not happen again on replay. ```kotlin import io.cratis.chronicle.observation.Reactor import io.cratis.chronicle.observation.Replay @Reactor class ShippingNotifications { fun orderPlaced(event: OrderPlaced) { println("Emailing the customer about ${event.orderId}") } @Replay fun orderPlacedDuringReplay(event: OrderPlaced) { println("Rebuilding ${event.orderId} without emailing anyone") } } ``` *** ## @Reducer Marks a class as a reducer. Each public method folds one event type into the read model. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `id` | `String` | `""` | Stable identifier. Defaults to class name. | | `eventSequence` | `String` | event log | The event sequence to observe. Overridden by [@EventSequence](#eventsequence). | | `isActive` | `Boolean` | `true` | Whether the kernel runs the reducer. | A passive reducer (`isActive = false`) stores nothing: reading its read model folds the event source’s events from the reducer’s event sequence in the client on each read, so the result is current. The cost is a read of those events on every call. A reducer whose read model carries [@Passive](#passive) is passive too. A handler takes the event, the state so far, and optionally an `EventContext`. The state is `null` until the first event for an event source has been folded in. ```kotlin import io.cratis.chronicle.events.EventContext import io.cratis.chronicle.observation.Reducer @Reducer class OrderSummaryReducer { fun orderPlaced(event: OrderPlaced, state: OrderSummary?): OrderSummary = (state ?: OrderSummary()).copy(orderId = event.orderId) fun orderShipped( event: OrderShipped, state: OrderSummary?, context: EventContext ): OrderSummary = (state ?: OrderSummary()).copy(status = "shipped at ${context.occurred}") } ``` *** ## @ReadModel Marks a data class as a Chronicle read model. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `id` | `String` | `""` | Stable identifier. Defaults to class name. | | `displayName` | `String` | `""` | Human-readable label. Defaults to name. | ```kotlin import io.cratis.chronicle.readModels.ReadModel @ReadModel data class OrderSummary(val orderId: String = "", val status: String = "pending") ``` *** ## @Passive Marks a read model’s projection as passive: registered with the kernel but not actively run, and not stored. `getInstanceByKey` computes a passive read model from its events on every read, so the result includes an event appended immediately before. Use it for a read model that is read rarely or must be current, and keep the default for one that is read often. Placed on the read model class; applies to model-bound projections, to a fluent projection whose read model carries it, and to the reducer that produces it. Requires client 6.5.0 or later. A passive projection is computed from the events of the key you ask for, so it suits projections keyed by event source id. A projection that takes its key from an event property or joins events from other event sources needs the stored, active form to be complete. No parameters. ```kotlin import io.cratis.chronicle.events.EventType import io.cratis.chronicle.projections.FromEvent import io.cratis.chronicle.readModels.Passive import io.cratis.chronicle.readModels.ReadModel @EventType data class SnapshotCreated(val data: String) @Passive @ReadModel @FromEvent(SnapshotCreated::class) data class HistoricalSnapshot(val data: String = "") ``` *** ## @Projection Marks a class as a Chronicle projection, or overrides the projection identifier on a model-bound read model. It is optional — when omitted, the class simple name is used as the identifier. For a declarative projection the read model type is inferred from the `IProjectionFor` type parameter. For a model-bound projection the annotated class is itself the read model. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `id` | `String` | `""` | Stable identifier. Defaults to class name. | | `eventSequence` | `String` | event log | The event sequence to observe. Overridden by [@EventSequence](#eventsequence). | ```kotlin import io.cratis.chronicle.projections.FromEvent import io.cratis.chronicle.projections.Projection import io.cratis.chronicle.readModels.ReadModel @ReadModel @Projection(eventSequence = "outbox") @FromEvent(OrderPlaced::class) data class OutboxOrderTracking(val orderId: String = "") ``` *** ## @EventSequence Points an observer — a reactor, a reducer or a projection — at the event sequence it observes. This is the standalone alternative to the `eventSequence` parameter on [@Reactor](#reactor), [@Reducer](#reducer) and [@Projection](#projection); reach for it when the sequence is the only thing being configured, so the observer keeps its conventional identifier. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `value` | `String` | *(required)* | The event sequence to observe. | ```kotlin import io.cratis.chronicle.observation.EventSequence import io.cratis.chronicle.observation.Reactor @Reactor @EventSequence("outbox") class OutboxOrderNotifications { fun orderPlaced(event: OrderPlaced) { println("Order ${event.orderId} placed, observed from the outbox") } } ``` When both this annotation and the `eventSequence` parameter are present, this annotation wins. There is no `@EventLog` counterpart. In the .NET client it exists to override the inbox routing that `[EventStore]` on an event type turns on — and the Kotlin client has no `@EventStore`, so an observer without an explicit sequence already reads from the event log. *** ## @Constraint Marks a class as a Chronicle constraint definition. The class must implement `IConstraint`. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `id` | `String` | `""` | Stable identifier. Defaults to class name. | *** ## @Unique Marks a property or an event type as needing to be unique - the model-bound alternative to a hand-written `IConstraint`. On a property, no two events of that type may carry the same value; applying it with the same `id` to properties on more than one event type groups them under one constraint, checked across all of them combined. On an event type, at most one instance of that type may exist per event source. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `id` | `String` | `""` | Constraint name, defaulting to the property/class. | | `message` | `String` | `""` | Message for a constraint violation. | ```kotlin import io.cratis.chronicle.constraints.Unique import io.cratis.chronicle.events.EventType @EventType data class ProjectCreated(@Unique val name: String, val description: String) @EventType @Unique data class WorkspaceClaimed(val slug: String) ``` Pair it with [@RemoveConstraint](#removeconstraint) on a removal event to release the value for reuse. *** ## @RemoveConstraint Marks an event type as releasing a named [@Unique](#unique) constraint when it is appended - typically a deletion or lifecycle-ending event. Repeatable, so one event can release more than one constraint. Only one event type may release a given constraint name with this client - if more than one declares the same name, registration keeps the first one it finds and reports the rest, rather than silently overwriting on every reconnect. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `value` | `String` | *(required)* | Name of the constraint to release. | ```kotlin import io.cratis.chronicle.constraints.RemoveConstraint import io.cratis.chronicle.events.EventType @EventType @RemoveConstraint("UniqueWorkspaceSlug") data class WorkspaceArchived(val workspaceId: String) ``` *** ## @Seeder Marks a class as a Chronicle event seeder. The class must implement `ICanSeedEvents`. *** ## @Pii Marks a property, constructor parameter, field, or type as personally identifiable information. Chronicle encrypts annotated values at rest using a per-subject key. See [PII Attribute](/chronicle/compliance/pii/) for the full compliance model this participates in. Applying it directly to a property works, but the declare-once pattern is to put it on a `ConceptAs` type instead: every event or read model property that reuses that concept is PII automatically, with nothing to repeat at each call site. It can also mark a composite value object type, in which case every value the type holds is treated as PII wherever that type appears. `@Pii` cannot be applied to an `EventSourceId` concept — Chronicle uses the event source id to look up the encryption key for every other PII value belonging to that source, so encrypting the id itself would make its own key unfindable. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `description` | `String` | `""` | Note about what the field holds. | ```kotlin import io.cratis.chronicle.compliance.Pii import io.cratis.chronicle.events.EventType // Declared once on the concept, every event or read model property that // reuses EmailAddress is PII automatically. @Pii(description = "Customer email address") data class EmailAddress(val value: String) : io.cratis.fundamentals.concepts.ConceptAs { override fun value(): String = value } @EventType data class CustomerRegistered( val customerId: String, val email: EmailAddress ) ``` *** ## @JsonSchemaType Overrides the type a class is represented as in the generated JSON schema. Apply it to a type that brings its own serializer and writes something other than its own shape on the wire — a value object collapsed into a single string, for instance. Without it the generated schema would describe the Kotlin shape, and the value would not round-trip through the kernel. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `type` | `KClass<*>` | *(required)* | Type the class is represented as. | ```kotlin import io.cratis.chronicle.schemas.JsonSchemaType // Money serializes as a single string ("42.50 USD") through its own // serializer, so the schema needs to describe a string, not an object // with amount/currency fields. @JsonSchemaType(String::class) data class Money(val amount: Double, val currency: String) ``` Pointing the annotation at the annotated type itself throws `SelfReferencingJsonSchemaType` — generating that schema would recurse forever. *** ## @Subject Marks a property as the compliance subject - the identity a release decrypts [@Pii](#pii) values against. `IReadModelsService.release` uses it to pick which property carries the subject; without it, release falls back to a property named `id` (case-insensitive), the convention every read model followed before this annotation existed. Add it whenever a read model’s subject is not its `id` - for example a support ticket keyed by ticket id but holding a customer’s PII, where the customer, not the ticket, is who the encryption key belongs to. No parameters. ```kotlin import io.cratis.chronicle.Subject import io.cratis.chronicle.readModels.ReadModel @ReadModel data class SupportTicketSummary( val id: String = "", @Subject val customerId: String = "", val topic: String = "" ) ``` *** ## @FromEvent Applied to a read model class to declare that its fields are mapped from an event type. Part of the annotation-based projection style. It is repeatable — apply it once per event type the read model projects from. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | *(required)* | The source event class. | | `key` | `String` | `"EventSourceId"` | Correlates events to instances. | ```kotlin import io.cratis.chronicle.events.EventType import io.cratis.chronicle.projections.FromEvent import io.cratis.chronicle.readModels.ReadModel @EventType data class OrderShipped(val orderId: String, val carrier: String) @ReadModel @FromEvent(OrderPlaced::class) @FromEvent(OrderShipped::class) data class OrderTracking( val orderId: String = "", val status: String = "" ) ``` *** ## @Key Marks a property on an event as the key a projection correlates that event to a read model instance by. [@FromEvent](#fromevent)’s `key` parameter takes this today as a bare property-name string; `@Key` is the strongly-typed alternative for consumers that resolve the key by reflection instead of by name. No parameters. ```kotlin import io.cratis.chronicle.events.EventType import io.cratis.chronicle.keys.Key @EventType data class PickTicketOpened(@Key val orderId: String, val warehouse: String) ``` *** ## @ContextKey Marks a function as deriving its key from the event context — for example the event source id, or a correlation id — rather than from a property on the event payload. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `property` | `String` | *(required)* | `EventContext` property to use. | ```kotlin import io.cratis.chronicle.keys.ContextKey class PickTicketHandlers { @ContextKey(property = "EventSourceId") fun pickTicketOpened(event: PickTicketOpened) = Unit } ``` `IKeyBuilder`/`KeyBuilder` build the same resolution fluently instead of declaratively — see the `io.cratis.chronicle.keys` package. *** ## @SetFrom Applied to a read model property to override auto-mapping by name and declare which event field populates it. It is repeatable, so one property can be mapped differently per event type. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `propertyPath` | `String` | `""` | Path to the source property. | | `eventType` | `KClass<*>` | `Nothing::class` | Event it applies to. | `propertyPath` is a dot-separated path on the event, and defaults to the annotated property’s own name. The default `eventType` applies the mapping to every event in the read model’s `@FromEvent` list that has a matching source property. *** ## @SetValue Sets a read model property to a constant value when a specific event occurs, or clears it back to no value. It is repeatable, so the same property can hold a different constant per event type. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class to trigger on. | | `value` | `String` | `""` | The constant’s literal text. Ignored if `clear`. | | `clear` | `Boolean` | `false` | Clears instead of setting `value`. | Kotlin annotation parameters cannot be nullable, so `value` is always a plain string — a numeric or boolean constant is written out as its literal text (`"42"`, `"true"`) rather than as a typed argument. Set `clear = true` to clear the property instead, which is Kotlin’s equivalent of passing `null` for `value` in the .NET client. ```kotlin import io.cratis.chronicle.events.EventType import io.cratis.chronicle.projections.FromEvent import io.cratis.chronicle.projections.SetValue import io.cratis.chronicle.readModels.ReadModel @EventType data class SubscriptionActivated(val placeholder: Boolean = true) @EventType data class SubscriptionCanceled(val placeholder: Boolean = true) @ReadModel @FromEvent(SubscriptionActivated::class) @FromEvent(SubscriptionCanceled::class) data class Subscription( @SetValue(SubscriptionActivated::class, value = "active") @SetValue(SubscriptionCanceled::class, value = "canceled") val status: String = "" ) ``` *** ## @SetFromContext Maps a read model property from a named `EventContext` property, for one specific event. Unlike [@FromAll / @FromEvery](#fromall--fromevery), which map a context property across every event the projection observes, this ties the mapping to a single event type. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class this mapping applies to. | | `contextProperty` | `String` | `""` | The context property to read from. | `contextProperty` defaults to the annotated property’s own name. ```kotlin import io.cratis.chronicle.events.EventType import io.cratis.chronicle.projections.FromEvent import io.cratis.chronicle.projections.SetFrom import io.cratis.chronicle.projections.SetFromContext import io.cratis.chronicle.readModels.ReadModel @EventType data class OrderPlacedForAudit(val customerName: String) @ReadModel @FromEvent(OrderPlacedForAudit::class) data class AuditedOrder( @SetFrom("customerName", OrderPlacedForAudit::class) val customerName: String = "", @SetFromContext(OrderPlacedForAudit::class, contextProperty = "occurred") val orderedAt: String = "" ) ``` *** ## @Join Populates a read model property by joining against another event type on its event source id. Use it when the triggering event doesn’t carry the read model’s own key but instead references another entity by id. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class to join against. | | `on` | `String` | `""` | Read model property to join on. | | `eventPropertyName` | `String` | `""` | Property on `eventType` to read. | `on` and `eventPropertyName` both default to the annotated property’s own name. ```kotlin import io.cratis.chronicle.projections.Join import io.cratis.chronicle.readModels.ReadModel @ReadModel data class OrderWithCustomerEmail( val orderId: String = "", val customerId: String = "", @Join( eventType = CustomerRegistered::class, on = "customerId", eventPropertyName = "email" ) val customerEmail: String = "" ) ``` *** ## @ChildrenFrom Declares that a collection property is populated with child read model instances created or updated by a specific event type. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class that creates children. | | `key` | `String` | `"EventSourceId"` | Event property identifying the child. | | `identifiedBy` | `String` | `""` | Child’s own identity property. | | `parentKey` | `String` | `"EventSourceId"` | Event property for the parent. | `identifiedBy` defaults to the child type’s `id`/`key` property, falling back to `EventSourceId`. *** ## @Nested Marks a single nullable property as a nested sub-object built from its own type’s `@FromEvent`/`@SetFrom` annotations. No parameters. *** ## @ClearWith Declares which event clears (nulls out) a `@Nested` property. Placed on the nested type itself, alongside its `@FromEvent` annotation. | Parameter | Type | Description | | --- | --- | --- | | `eventType` | `KClass<*>` | The event class that clears the nested object. | *** ## @Count Turns a property into an occurrence counter for a specific event type — every time `eventType` fires for the read model instance, the property is bumped by one. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class to count occurrences of. | | `constantKey` | `String` | `""` | See “Constant keys” below. | *** ## @Increment / @Decrement Bumps a numeric property up (`@Increment`) or down (`@Decrement`) by one every time `eventType` fires for the read model instance. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class that bumps the property. | | `constantKey` | `String` | `""` | See “Constant keys” below. | ```kotlin import io.cratis.chronicle.projections.Increment import io.cratis.chronicle.readModels.ReadModel @ReadModel data class OrderShipmentStats( val orderId: String = "", @Increment(OrderShipped::class) val shipmentCount: Int = 0 ) ``` **Constant keys:** when `constantKey` is set on `@Count`, `@Increment`, or `@Decrement`, every occurrence of `eventType` updates the same read model instance, identified by that constant value, instead of the projection’s normal per-instance key resolution. *** ## @AddFrom / @SubtractFrom Adds (`@AddFrom`) or subtracts (`@SubtractFrom`) the value of an event property into/from a numeric property every time `eventType` fires. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class carrying the value. | | `eventPropertyName` | `String` | `""` | Property on `eventType` to read. | `eventPropertyName` defaults to the annotated property’s own name. *** ## @FromAll / @FromEvery Projects a property from every event type the projection observes, rather than a single one. `@FromAll` and `@FromEvery` are equivalent aliases. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `property` | `String` | `""` | Triggering event property to read from. | | `contextProperty` | `String` | `""` | Event context property to read from. | Both default to the annotated property’s own name. `contextProperty` (e.g. the causing identity) takes precedence over `property` when both are set. *** ## @NotRewindable Marks a projection as forward-only — it cannot be rewound and replayed from scratch. Placed on the read model class. No parameters. *** ## @RemovedWith Declares which event removes a read model instance, or — when placed on a `@ChildrenFrom` property — which event removes a single child from that collection. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class that triggers removal. | | `key` | `String` | `"EventSourceId"` | Event property for what to remove. | | `parentKey` | `String` | `"EventSourceId"` | Event property for the parent. | `parentKey` only applies when removing a single child from a `@ChildrenFrom` collection. *** ## @RemovedWithJoin Like `@RemovedWith`, but the removal event doesn’t directly carry the id — it’s resolved via a join instead. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `eventType` | `KClass<*>` | — | The event class that triggers the removal. | | `key` | `String` | `"EventSourceId"` | Property used in the join lookup. | *** ## @NoAutoMap Disables AutoMap. Placed on a read model, `@ChildrenFrom` element, or `@Nested` type, it disables AutoMap entirely for that type. Placed on a single property, it excludes just that property from AutoMap while siblings keep auto-mapping. No parameters. *** ## @Webhook Marks a class as a discoverable Chronicle webhook definition. The class must implement `IWebhookDefiner`. | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `id` | `String` | `""` | Stable identifier. Defaults to class name. | | `targetUrl` | `String` | — | The URL to send events to. | ```kotlin import io.cratis.chronicle.webhooks.IWebhookDefiner import io.cratis.chronicle.webhooks.IWebhookDefinitionBuilder import io.cratis.chronicle.webhooks.Webhook @Webhook(targetUrl = "https://hooks.example.com/orders") class OrderPlacedWebhook : IWebhookDefiner { override fun define(builder: IWebhookDefinitionBuilder) { builder.withEventType(OrderPlaced::class) } } ``` # Configuration > Every ChronicleOptions property, the chronicle:// connection string format and its options, TLS and authentication, and namespaces for the JVM client. ## ChronicleOptions ```kotlin data class ChronicleOptions( val connectionString: ChronicleConnectionString, val programIdentifier: String = "Unknown", val defaultSinkTypeId: String = System.getenv("CHRONICLE_SINK_TYPE") ?: WellKnownSinkTypes.MONGODB, val autoDiscoverAndRegister: Boolean = true, val artifacts: IClientArtifacts = ClientArtifacts.default, val artifactActivator: IArtifactActivator = ArtifactActivator, val openTelemetry: OpenTelemetry? = null ) ``` | Property | Default | Description | | --- | --- | --- | | `connectionString` | *(required)* | Parsed address of the server | | `programIdentifier` | `"Unknown"` | Name of the connecting program | | `defaultSinkTypeId` | `MongoDB` | Sink for reducers and projections | | `autoDiscoverAndRegister` | `true` | Register artifacts on connect | | `artifacts` | `ClientArtifacts.default` | What the application is made of | | `artifactActivator` | `ArtifactActivator` | How artifacts are created | | `openTelemetry` | `null` | Where spans go — see [Tracing](/chronicle/clients/kotlin/guides/tracing/) | | `readModelNamingPolicy` | `DefaultReadModelNamingPolicy` | Names each read model’s container — see [Read model container names](#read-model-container-names) | `programIdentifier` is a human-readable label that shows up in diagnostics. `defaultSinkTypeId` defaults to `WellKnownSinkTypes.MONGODB`, and can be overridden per process with the `CHRONICLE_SINK_TYPE` environment variable — for example `CHRONICLE_SINK_TYPE=SQL`. The last three control artifact discovery and registration. Two helpers cover the common adjustments — see [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/) for the full picture: ```kotlin ChronicleOptions.development().withoutAutoRegistration() ChronicleOptions.development().withArtifactsFrom("com.acme.ordering") ``` `readModelNamingPolicy` is not a constructor parameter. Set it with `withReadModelNamingPolicy`, described below. `copy` does not carry it over, so set it again after copying. There are two factories on the companion object: ```kotlin ChronicleOptions.fromConnectionString( "chronicle://chronicle.internal:35000?skipTlsValidation=false" ) ChronicleOptions.development() ``` Both are `@JvmStatic`, so Java calls them as ordinary static methods: ```java ChronicleOptions.fromConnectionString( "chronicle://chronicle.internal:35000?skipTlsValidation=false"); ChronicleOptions.development(); ``` ## Read model container names Every read model is stored in a container: a MongoDB collection or a SQL table. By default the client names it after the read model’s identifier — the `@ReadModel(id = ...)` value, or the class simple name. A `Customer` read model is stored in a container called `Customer`. That is not always the name another layer expects. Arc’s MongoDB naming policy, for example, pluralizes, so it reads `Customers` while the client writes `Customer`. A `ReadModelNamingPolicy` decides the container name instead: ```kotlin fun interface ReadModelNamingPolicy { fun getReadModelName(readModelClass: Class<*>): String } ``` Set one on the options. The policy names only the container. The read model identifier, and everything keyed on it — projections, reducers, observers, and the queries that read a model back — stays as it was. ```kotlin import io.cratis.chronicle.readModels.ReadModelNamingPolicy val options = ChronicleOptions.development() .withReadModelNamingPolicy( ReadModelNamingPolicy { "${it.simpleName}s" } ) ``` ```java import io.cratis.chronicle.ChronicleOptions; var options = ChronicleOptions.development() .withReadModelNamingPolicy( readModelClass -> readModelClass.getSimpleName() + "s"); ``` A policy that already exists as an object, such as Arc’s, plugs in by delegating to it from the lambda. In a Spring Boot application, declare a `ReadModelNamingPolicy` bean and the starter uses it — see [Spring Boot](/chronicle/clients/kotlin/guides/spring-boot/#replacing-what-the-starter-provides). ## Connection string format ```text chronicle://[:][,[:]...][?] chronicle://:@[:][,...][?] chronicle+srv://[:][?] ``` The port defaults to `35000` when omitted. IPv6 literals use bracket notation — `chronicle://[::1]:35000`. A `chronicle+srv://` connection string accepts exactly one host, and DNS SRV records supply the real targets and ports. ### Query string options | Option | Default | Description | | --- | --- | --- | | `disableTls` | `false` | Connect over plaintext instead of TLS | | `skipTlsValidation` | `true` | Accept any server certificate | | `apiKey` | *(none)* | Rejected at client creation; see below | | `loadBalancer` | `least-connections` | Policy across multiple addresses | | `srvNameServer` | *(none)* | DNS server for `chronicle+srv://` | `loadBalancer` accepts `least-connections`, `round-robin` or `random`. Option names are matched without regard to case. An option name the client does not recognize is ignored without an error, so check the spelling of `skipTlsValidation` in particular: a misspelled one leaves certificate validation off. An unknown `loadBalancer` value throws `IllegalArgumentException`. `skipTlsValidation` accepts self-signed certificates. Set it to `false` to require full certificate chain validation. ```kotlin val options = ChronicleOptions.fromConnectionString( "chronicle://my-client:my-secret@chronicle.internal:35000?skipTlsValidation=false" ) ``` ### Round-tripping to a string `ChronicleConnectionString.toString()` renders a parsed connection string back to its `chronicle://`/`chronicle+srv://` textual form. The result isn’t guaranteed to be byte-identical to whatever was originally parsed — for example a host without an explicit port is rendered with the resolved default port — but re-parsing it always yields an equal `ChronicleConnectionString`. This is useful for storing a connection string that was built up programmatically rather than typed by hand. The rendered string includes the password, so do not write it to logs; log its `target` (the first host and port) instead: ```kotlin import io.cratis.chronicle.connection.ChronicleConnectionString val original = ChronicleConnectionString.parse( "chronicle://my-client:my-secret@chronicle.internal:35000?skipTlsValidation=false" ) val rendered = original.toString() val reparsed = ChronicleConnectionString.parse(rendered) check(reparsed == original) ``` ## Development shortcut ```kotlin val client = ChronicleClient(ChronicleOptions.development()) ``` Equivalent to a connection string of `chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000`. It connects over TLS but skips certificate validation, so it works against the Kernel’s self-signed development certificate without further configuration. ## TLS and authentication The client connects over TLS by default. Certificate validation is skipped unless you set `skipTlsValidation=false`, which makes the client validate the certificate chain against the JVM’s default trust store. Set `disableTls=true` only for plaintext environments. Every connection string skips certificate validation by default `skipTlsValidation` defaults to `true` for every connection string, not only for `development()`. Against anything other than a kernel on your own machine, add `?skipTlsValidation=false`. Otherwise the client accepts any certificate and sends its client secret to whoever answers. The client authenticates with a client id and secret: the user name and password in the connection string’s user info section. It exchanges them for an access token at the kernel’s `/connect/token` endpoint. Without user info it uses the development credentials, `chronicle-dev-client` and `chronicle-dev-secret`. Read the secret from the environment or a secret store rather than writing it into source code. The connection string is parsed as text: everything after the first `?` is read as options, and nothing is URL-decoded. A secret that contains `?` cannot be written into it. Construct the connection string from its parts instead: ```kotlin import io.cratis.chronicle.connection.ChronicleConnectionString import io.cratis.chronicle.connection.ChronicleServerAddress val options = ChronicleOptions( ChronicleConnectionString( addresses = listOf(ChronicleServerAddress("chronicle.internal", 35000)), username = "my-client", password = System.getenv("CHRONICLE_CLIENT_SECRET") ?: error("Set CHRONICLE_CLIENT_SECRET"), skipTlsValidation = false ) ) ``` From Java, the constructor takes all nine properties in declaration order. apiKey is rejected The Chronicle kernel has no API key authentication. From 6.5.0, creating a client from a connection string with an `apiKey` option throws `IllegalArgumentException`; up to 6.4.0 the option was accepted and the kernel received no credentials at all. Use a client id and secret. A kernel that is unreachable, or that rejects the client’s TLS settings, makes the client constructor throw. Wrong credentials are detected once the client tries to connect: the first append or `awaitRegistration()` throws `ChronicleConnectionFailed`. See [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/) for how each failure shows up. ## Namespace See [Namespaces](/chronicle/concepts/namespaces/) for the full tenancy model. The default namespace is `"Default"`. Override it when calling `getEventStore`: ```kotlin val store = client.getEventStore("MyApp", namespace = "production") ``` From Java, `BlockingChronicleClient` has both forms, so you can name the namespace or leave it at the default: ```java import io.cratis.chronicle.ChronicleOptions; import io.cratis.chronicle.java.BlockingChronicleClient; import io.cratis.chronicle.java.BlockingEventStore; var client = BlockingChronicleClient.connect(ChronicleOptions.development()); BlockingEventStore store = client.getEventStore("MyApp", "production"); ``` `ChronicleClient.getEventStore` declares the namespace as a Kotlin default parameter without `@JvmOverloads`, so Java callers of that method must pass both arguments. See [Java interop](/chronicle/clients/kotlin/reference/event-store-api/#java-interop). # Connection lifecycle > What the JVM client does when it connects, checks compatibility, registers artifacts, loses the kernel and reconnects, and where each failure shows up. Most connection problems look the same from the outside: a call that throws, or a call that never returns. This page lists what the Kotlin and Java client does at each stage of a connection, and what you see when a stage fails. All of it applies equally to `ChronicleClient`, `BlockingChronicleClient` and the Spring Boot starter, which is built on them. It describes `io.cratis:chronicle` 6.5.0. ## Creating the client `ChronicleClient(options)` and `BlockingChronicleClient.connect(options)` dial the kernel before they return. Dialing: 1. Resolves the address. A `chronicle+srv://` connection string is looked up in DNS; a list of hosts is narrowed to one by the `loadBalancer` policy. 2. Fetches an access token from the kernel’s `/connect/token` endpoint with the connection string’s user name and password (the development credentials when none are given). A failed token request is written to standard error but does not fail the constructor; the failure shows up later. A connection string with an `apiKey` option is rejected before this step with `IllegalArgumentException`, because the kernel has no API key authentication. 3. Runs a compatibility check: the client sends its client type (`Kotlin`), its version and the contract descriptors it was built with, and the kernel answers whether it can serve them. The check has a 10-second deadline. If the address cannot be reached or the compatibility check fails, the constructor throws and no operation is sent. There is no lazy connect. * **Nothing listening, or the wrong host or port:** `io.grpc.StatusException: UNAVAILABLE`. * **`skipTlsValidation=false` and a certificate the JVM does not trust:** `io.grpc.StatusException: UNAVAILABLE`, with `Failed to fetch OAuth2 token: … PKIX path building failed` on stderr. * **The kernel rejects the client’s contracts:** `IllegalStateException` with the message `Chronicle server is incompatible: . No operations were sent.` Credentials are not checked at this point. With wrong credentials the client is created without error; the kernel then refuses the connection, and the first append or `awaitRegistration()` throws `ChronicleConnectionFailed`. See [Rejected connections](#rejected-connections). In a Spring Boot application the client is a bean, so an unreachable kernel at startup fails the application context with `APPLICATION FAILED TO START`. ## Compatibility There is no published compatibility matrix between client and kernel versions. The compatibility check at connect time is what enforces it: an incompatible pairing fails before anything is sent. What has been checked for this page: client `io.cratis:chronicle` 6.5.0 against kernel image `cratis/chronicle:19.6.1-development`. The client connects, appends, runs reactors and reducers, and reads projections and reducers, including passive ones. Kernel 19.4.8 rejects client 6.5.0 in the compatibility check, so upgrade the kernel before the client. Client 6.4.0 connects to 19.4.8. Client 6.5.0 depends on `io.cratis:chronicle-contracts` 19.5.0. That is a build dependency, not a statement about which kernel versions work. Treat any pairing not listed above as unverified until the compatibility check and your own tests pass against it. ## Staying connected Once connected, the client holds a connection stream open. The kernel sends a keep-alive every second and the client answers each one. If no keep-alive arrives for 5 seconds, or the stream fails, the client treats the connection as lost and reconnects: * It waits with exponential backoff, starting at about 1 second and capped at 30 seconds, with jitter so a fleet of clients does not return all at once. * It retries forever, until you dispose the client. * Every attempt dials from scratch: DNS and SRV are resolved again, a host is picked again, a new token is fetched and the compatibility check runs again. * Every reconnect uses a new connection id. The kernel drops a client it stops hearing from, together with that client’s observers. * After each reconnect, the client registers every discovered artifact again and each reactor and reducer re-establishes its observation. A kernel that restarted and forgot everything is told it all again. While the connection is down, reactors and reducers receive nothing, and calls that need the kernel fail or wait. Appends that fail throw a gRPC `StatusException`. The client writes connection problems to standard error, not to a logging framework. Look for lines such as: ```text [Chronicle] Connection lost: UNAVAILABLE: io exception [Chronicle] Connection lost: UNAUTHENTICATED: HTTP status code 401 [Chronicle] Failed to fetch OAuth2 token: Token request failed with status 401 ``` ## Registration and the first append With automatic registration on (the default), the client registers every discovered artifact each time a connection is established. See [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/) for the order. The first append on an event store waits until the first registration pass has run, so it cannot reach a kernel that does not know its event type yet. `awaitRegistration()` waits for the same thing, so you can put that wait at startup instead of on the first request. Neither guarantees that registration succeeded. A pass that throws still lets the waiting calls through, so an append fails with a clear error instead of hanging. The failure is written to standard error as `[EventStore] Automatic registration of artifacts failed: `, and the pass is tried again on the next reconnect. A reactor or reducer that cannot be started is reported on its own line and retried on the next pass, without holding back the others; see [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/#when-registration-fails). ## Rejected connections When the kernel refuses the connection, because the credentials are wrong (`UNAUTHENTICATED`), the client is not permitted (`PERMISSION_DENIED`), or the server turns out to be incompatible on a reconnect, the waiting calls do not hang. `awaitRegistration()` and the first append throw `io.cratis.chronicle.connection.ChronicleConnectionFailed`, whose message names the cause. From Java, the blocking calls throw the same exception. The client keeps retrying in the background. Once the kernel accepts a connection, for example after the credentials are corrected on the server, later calls go through normally. ## Bound the first call Transient failures are different: when the kernel is unreachable or stops answering after the client was created, the client keeps retrying, and the first append and `awaitRegistration()` wait for it. The registration wait has no time limit of its own. Put a limit on it at startup and fail with a message that points at the cause: ```kotlin import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout try { withTimeout(30_000) { store.awaitRegistration() } } catch (timeout: TimeoutCancellationException) { throw IllegalStateException( "Chronicle was not ready within 30 seconds; check stderr for the cause", timeout ) } ``` Java has no cancellable wait, so run the blocking call on another thread and stop waiting for it: ```java import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; try { CompletableFuture.runAsync(store::awaitRegistration).get(30, TimeUnit.SECONDS); } catch (TimeoutException timeout) { throw new IllegalStateException( "Chronicle was not ready within 30 seconds; check stderr for the cause", timeout); } ``` The thread running `awaitRegistration` stays blocked until the client connects; disposing the client does not release it. That is acceptable at startup, where the usual response to the timeout is to stop the process. The Spring Boot starter bounds this wait for you with `cratis.chronicle.registration-timeout`; see [Spring Boot](/chronicle/clients/kotlin/guides/spring-boot/#startup). ## Reading after writing A successful append means the event is stored in the event log. Reactors, reducers and projections process it afterwards, and a materialized read model is written to its store after that. A read immediately after the append can therefore return `null` or the previous state. See [step 8 of Get started](/chronicle/clients/kotlin/get-started/#8-query-a-read-model-by-key) for a bounded wait. A passive read model ([`@Passive`](/chronicle/clients/kotlin/reference/annotations/#passive), or a reducer with `isActive = false`) is not stored: it is computed from the events on each read, so it includes the event you just appended. See the shared [read models](/chronicle/read-models/) documentation for designing around it. ## Disposing Call `dispose()` (or `close()`, or use try-with-resources on `BlockingChronicleClient`) when the application stops. It stops reconnecting, closes the connection, and gives in-flight calls up to 5 seconds to finish. Reactors and reducers that were observing report `failed: UNAVAILABLE: Channel shutdownNow invoked` on standard error as they stop; during shutdown that line is expected. ## Troubleshooting **The constructor throws `UNAVAILABLE`.** The kernel is not reachable, or the TLS settings do not match it. Check `curl -sk https://:/health`, and check `disableTls` and `skipTlsValidation` in [Configuration](/chronicle/clients/kotlin/reference/configuration/). **The constructor throws `… is incompatible …`.** The client and kernel versions cannot work together. Upgrade one of them; the message lists what does not match. **The first append or `awaitRegistration()` throws `ChronicleConnectionFailed`.** The kernel refused the connection. The message says why: `UNAUTHENTICATED` means the client id or secret is wrong. **The first append or `awaitRegistration()` never returns.** The kernel is not reachable, and the client is still retrying. Look for `Connection lost` on stderr, and bound the wait as shown above. **Reactors stopped firing.** The connection was lost and is not yet back. Look for `Connection lost` on stderr; the client reconnects on its own once the kernel is reachable. **`Automatic registration of artifacts failed` on stderr.** An artifact is invalid or could not be created. Fix the artifact named in the message; see [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/#when-registration-fails). **A read model is `null` right after appending.** It has not caught up yet. That is expected; see [Reading after writing](#reading-after-writing). # EventStore API > The IChronicleClient, IEventStore and service interfaces of the JVM client, and the blocking API for Java. ## IChronicleClient Entry point for the library. ```kotlin interface IChronicleClient : AutoCloseable { fun getEventStore( name: String, namespace: String = EventStoreNamespaceName.default.value ): EventStore suspend fun getEventStores(): List fun evictEventStores() fun dispose() } ``` Construct it with [ChronicleOptions](/chronicle/clients/kotlin/reference/configuration/): ```kotlin // localhost:35000 ChronicleClient(ChronicleOptions.development()) // any server ChronicleClient( ChronicleOptions.fromConnectionString("chronicle://chronicle.internal") ) ``` `getEventStores` lists every event store known to the kernel — not just the ones this client has already opened via `getEventStore`. `evictEventStores` clears this client’s internal cache of `EventStore` instances (and their per-store subscriptions) without disposing the client itself — useful between test classes that share a client instance. *** ## IEventStore ```kotlin interface IEventStore { val name: String val namespace: String val eventLog: IEventLog val reactors: IReactorsService val reducers: IReducersService val projections: IProjectionsService val constraints: IConstraintsService val seeding: IEventSeedingService val readModels: IReadModelsService val unitOfWorkManager: UnitOfWorkManager val compliance: IComplianceService val eventTypes: IEventTypesService val namespaces: INamespacesService val externalServices: IExternalServicesService val jobs: IJobsService val eventStoreSubscriptions: IEventStoreSubscriptionsService val webhooks: IWebhooksService val identities: IIdentityManagerService val failedPartitions: IFailedPartitions val observers: IObservers fun getEventSequence(id: EventSequenceId): IEventSequence } ``` Every service is now on the interface itself — `compliance`, `eventTypes`, `namespaces`, `externalServices`, `jobs`, `eventStoreSubscriptions`, `webhooks`, and `identities` used to be available only on the concrete `EventStore` class; code written against `IEventStore` (for example in tests, behind a fake) now sees the full surface. `eventLog` is the default event log sequence (`EventSequenceId.eventLog`). Use `getEventSequence(id)` to get any other, non-default `IEventSequence` by id — for example one used exclusively by a specific subsystem. *** ## IEventSequence and IEventLog `IEventLog` is `IEventSequence` plus a `transactional` entry point; both share the same read/write surface. ```kotlin interface IEventSequence { val id: EventSequenceId val appendOperations: SharedFlow> suspend fun append( eventSourceId: String, event: Any, options: AppendOptions? = null ): AppendResult suspend fun appendMany( eventSourceId: String, events: List, options: AppendOptions? = null ): List suspend fun appendMany( events: List, concurrencyScopes: Map = emptyMap(), correlationId: UUID? = null ): List suspend fun hasEventsFor(eventSourceId: String): Boolean suspend fun getTailSequenceNumber( eventSourceId: String? = null ): EventSequenceNumber suspend fun getForEventSourceIdAndEventTypes( eventSourceId: String, eventTypes: List>, eventStreamType: String? = null, eventStreamId: String? = null, eventSourceType: String? = null ): List suspend fun getFromSequenceNumber( sequenceNumber: EventSequenceNumber, eventSourceId: String? = null, eventTypes: List>? = null ): List suspend fun getNextSequenceNumber(): EventSequenceNumber suspend fun getTailSequenceNumberForObserver( observerType: KClass<*> ): EventSequenceNumber suspend fun completeStream( eventStreamType: String, eventStreamId: String ): CompleteStreamResult suspend fun redact( sequenceNumber: EventSequenceNumber, reason: RedactionReason ) suspend fun redactForEventSource( eventSourceId: String, reason: RedactionReason, eventTypes: List> = emptyList() ) } interface IEventLog : IEventSequence { val transactional: ITransactionalEventSequence } ``` ### Appending across event sources The `eventSourceId` overload of `appendMany` shapes every event in the batch the same way. The `List` overload instead lets each event carry its own event source id and its own shaping, so one atomic batch can span many event sources and many streams. `concurrencyScopes` is keyed by event source id — only the sources present in the map are concurrency checked, and any source left out is appended unchecked. ```kotlin data class EventForEventSourceId( val eventSourceId: String, val event: Any, val eventStreamType: String? = null, val eventStreamId: String? = null, val eventSourceType: String? = null, val tags: List = emptyList(), val occurred: Instant? = null, val subject: String? = null, val causation: List = emptyList() ) ``` Every unset field falls back to the same default a plain `append` uses, resolved against that event’s own event source id. ### Composed operations `IEventSequenceOperations` composes a batch that is decided in more than one place, then commits it with a single `perform()`. Start one with the `operations()` or `forEventSourceId(...)` extension on any `IEventSequence`. Nothing reaches the kernel until `perform()` runs, and `getEventsToAppend()` shows exactly what it will send. ```kotlin interface IEventSequenceOperations { val eventSequence: IEventSequence fun forEventSourceId( eventSourceId: String, configure: IEventSourceOperations.() -> Unit ): IEventSequenceOperations fun withCorrelationId(correlationId: UUID): IEventSequenceOperations fun withCausation(causation: List): IEventSequenceOperations fun getAppendedEvents(): List fun getEventsToAppend(): List fun clear() suspend fun perform(): List } interface IEventSourceOperations { val operations: List val concurrencyScope: ConcurrencyScope fun withConcurrencyScope(concurrencyScope: ConcurrencyScope): IEventSourceOperations fun withConcurrencyScope( configure: ConcurrencyScopeBuilder.() -> Unit ): IEventSourceOperations fun append( event: Any, eventStreamType: String? = null, eventStreamId: String? = null, eventSourceType: String? = null, tags: List = emptyList(), occurred: Instant? = null, subject: String? = null ): IEventSourceOperations fun getOperationsOfType(type: KClass): List fun getAppendedEvents(): List } ``` Calling `forEventSourceId` twice for the same event source adds to what is already staged rather than replacing it. The concurrency scope lives on the event source, since that is where the kernel checks it: an event source that never sets one is appended unchecked, and a scope already set is never cleared by a later `ConcurrencyScope.notSet` — so a call that expresses no expectation cannot silently disable a check that was explicitly asked for. Causation is not composed here; it is derived from the ambient `CausationManager` when `perform()` runs. Java reaches this through `EventSequenceOperationsJavaBridge` (`operationsFor`, `forEventSourceId`, `perform`) and `EventSourceOperationsJavaBridge` (`append`, `withConcurrencyScope`), which supply the blocking calls, `Consumer`-based configuration, and the optional parameters Kotlin default arguments cannot give Java. ### Reading events back `getForEventSourceIdAndEventTypes` gets events for one event source, narrowed to specific event types and (optionally) a specific stream. `getFromSequenceNumber` instead reads forward from a position in the sequence, optionally narrowed by event source and/or event type — useful for catching up from a bookmark. `getTailSequenceNumber` and `getNextSequenceNumber` report the current end of the sequence (or of one event source’s events within it) and the sequence number the *next* appended event will receive, respectively. `getTailSequenceNumberForObserver` reports the tail relative to the event types a specific reactor or reducer type actually handles (discovered by reflection over its handler methods). Narrowing is opt-in everywhere here: a missing or empty filter means “do not narrow”, and no read, tail, or observer tail substitutes a route of its own. Since the kernel resolves an append that named no route onto stream type `All` and stream id `Default`, narrowing a read to `eventStreamType = "Default"` returns none of those events. The same holds for `eventSourceType`, where `Default` and an unspecified value both mean “do not narrow”. Requires Chronicle 18.5.0 Before 18.5.0 the request carried no event source type field, so the `eventSourceType` parameter of `getForEventSourceIdAndEventTypes` was accepted and silently discarded — a caller that narrowed by it got every source type back with no way to tell. Against an older kernel it still does nothing. ```kotlin data class AppendedEvent( val context: EventContext, val content: String ) ``` `content` is the event’s raw stored JSON — deserialize it with the concrete event class matching `context.eventType` to get a typed event object using `io.cratis.chronicle.json.chronicleGson` (`ChronicleGson.chronicleGson` in Java). ### Enum wire format Kotlin and Java enum values in event content and read-model state are written as JSON numbers: the constant’s zero-based ordinal. This also applies to enum values inside collections and maps. Enum map keys keep Gson’s existing text representation (the constant name unless `toString()` is overridden). The generated schema has `type: integer`, an `enum` array of ordinals, and `x-enumNames` metadata containing the names. Names are metadata, not alternative values accepted by the kernel’s integer schema. For compatibility with previously stored name-encoded payloads, the client reads names case insensitively as well as defined ordinals in numbers or numeric strings. Unknown names and undefined ordinals fail with an error naming the enum type and value. Unlike the .NET converter’s numeric-string parsing, JVM enums cannot represent undefined values, so an undefined numeric string is rejected too. Nullable enum values remain null. New events use numbers; existing name-encoded payloads remain readable. Keep the order of enum constants stable: reordering or inserting constants changes the meaning of previously stored ordinals. Add new constants at the end. ### Redacting events `redact` permanently rewrites a single event’s content, identified by its sequence number. `redactForEventSource` does the same for every event of a given event source, optionally narrowed to specific event types (an empty list, the default, redacts every event type for that source). **Both are destructive, irreversible content rewrites — not a soft delete or a field mask.** Once either call returns, the original content is gone from the event store for good. Use them only for a confirmed compliance/erasure request (e.g. GDPR “right to be forgotten”), typically alongside [`IComplianceService.deleteEncryptionKey`](#icomplianceservice) rather than instead of it — deleting the encryption key is non-destructive to event content (it just becomes unreadable), while redaction actually erases it. ```kotlin @JvmInline value class RedactionReason(val value: String) sealed class CompleteStreamResult { data class Success( val sequenceNumber: EventSequenceNumber ) : CompleteStreamResult() data object DefaultStreamCannotBeCompleted : CompleteStreamResult() data object AlreadyCompleted : CompleteStreamResult() } ``` ### Completing a stream `completeStream` marks an event stream type/id pair as closed so no further events can be appended to it, returning a `CompleteStreamResult`. The kernel’s protected default stream cannot be completed; it returns `DefaultStreamCannotBeCompleted`. Do not infer this rule from client-side routing defaults. Completing an already-completed stream returns `CompleteStreamResult.AlreadyCompleted` rather than throwing. ### Observing appends: `appendOperations` `appendOperations` is a hot `SharedFlow` that emits after every append made *through that specific `IEventSequence` instance* completes, whether it succeeded or failed — a single-event `append` emits a list of one element, a batch `appendMany` emits the whole batch. It does not emit for transactional appends made through `ITransactionalEventSequence` (those only emit, as part of the underlying batch, once the unit of work commits and the real append happens). Being a *hot* flow, only appends made after a subscriber starts collecting are seen — nothing is replayed. ```kotlin data class AppendedEventWithResult( val context: EventContext, val event: Any, val result: AppendResult ) ``` ### Concurrency control Routing values in `AppendOptions` and `EventForEventSourceId` are forwarded unchanged. Missing or empty routing is resolved by the kernel, not by the client. Explicit values such as `Default` or an event-source id are never rewritten. See [Migrate append routing](/chronicle/clients/kotlin/guides/migrate-append-routing/) for the breaking default-route change and legacy-route examples. Optimistic concurrency is opt-in per append, via `AppendOptions.concurrencyScope`; its defaults are independent of routing: ```kotlin data class AppendOptions( val correlationId: UUID? = null, val concurrencyScope: ConcurrencyScope? = null, val eventSourceType: String? = null, val eventStreamType: String? = null, val eventStreamId: String? = null, val subject: String? = null, val tags: List = emptyList(), val occurred: Instant? = null, val causation: List = emptyList() ) data class ConcurrencyScope( val sequenceNumber: EventSequenceNumber, val eventSourceId: Boolean = false, val eventStreamType: String? = null, val eventStreamId: String? = null, val eventSourceType: String? = null, val eventTypes: List = emptyList() ) ``` Build one with `ConcurrencyScopeBuilder` rather than the constructor directly — `withSequenceNumber` sets the expected position, and `withEventSourceId`/`withEventStreamType`/`withEventStreamId`/ `withEventSourceType`/`withEventType(s)` each narrow which dimension the check applies to. Leaving `concurrencyScope` unset (the default) is equivalent to `ConcurrencyScope.none` — the append is not concurrency checked. When the scope no longer matches, the append fails with `AppendResult.concurrencyViolation` set instead of throwing. ### Causation Every append carries a causation chain describing what led to it. By default that chain is ambient: `CausationManager` builds one up per thread, and the append reads it as it goes. Nearly every append should leave it at that. Set `AppendOptions.causation` to attribute an append to a different chain — an event imported from a legacy system, or a side effect that belongs to a chain of its own rather than to the work the current thread happens to be doing. An override deliberately leaves the ambient chain untouched, so later appends are not attributed to something they had nothing to do with. ```kotlin import io.cratis.chronicle.auditing.Causation import io.cratis.chronicle.auditing.CausationType import io.cratis.chronicle.eventSequences.AppendOptions import java.time.Instant store.eventLog.append( "employee-1", EmployeeHired("Ada", "Lovelace", "Engineer"), AppendOptions( causation = listOf( Causation( Instant.parse("1998-06-01T09:00:00Z"), CausationType("LegacyImport"), mapOf("file" to "1998.csv") ) ) ) ) ``` `EventForEventSourceId` carries the same field, which is how a reactor side effect names its own chain. One caveat applies to batches: the kernel carries a single chain per `appendMany`, not one per event, so a batch whose events declare different chains throws `CausationDiffersAcrossBatch` rather than silently keeping one of them. Give them the same causation, leave it unset, or append them as separate batches. For a composed operation, set it once for the whole batch with `withCausation`. From Java, use `AppendOptionsBuilder.causation(...)`. ### AppendResult | Property | Type | Description | | --- | --- | --- | | `isSuccess` | `Boolean` | `true` when there are no violations or errors | | `sequenceNumber` | `EventSequenceNumber` | Position in the event log | | `constraintViolations` | `List` | On failure | | `errors` | `List` | On failure | | `concurrencyViolation` | `ConcurrencyViolation?` | Set on stale scope | *** ## Unit of work `IEventStore.unitOfWorkManager` returns an `IUnitOfWorkManager`, which creates and tracks `IUnitOfWork` instances scoped to the current thread. ```kotlin import io.cratis.fundamentals.execution.CorrelationId interface IUnitOfWorkManager { val current: IUnitOfWork val hasCurrent: Boolean fun tryGetFor(correlationId: CorrelationId): IUnitOfWork? fun begin(): IUnitOfWork fun begin(correlationId: CorrelationId): IUnitOfWork fun setCurrent(unitOfWork: IUnitOfWork) } interface IUnitOfWork { val isCompleted: Boolean val correlationId: CorrelationId val isSuccess: Boolean fun addEvent( eventSequenceId: EventSequenceId, eventSourceId: String, event: Any, options: AppendOptions? = null ) fun getEvents(): List fun getConstraintViolations(): List fun getConcurrencyViolations(): List fun getAppendErrors(): List suspend fun commit() suspend fun rollback() fun onCompleted(callback: (IUnitOfWork) -> Unit) fun tryGetLastCommittedEventSequenceNumber(): EventSequenceNumber? } ``` `current` throws `NoUnitOfWorkHasBeenStarted` if `begin` hasn’t been called on this thread — check `hasCurrent` first if that’s expected. Prefer staging events through `store.eventLog.transactional.append`/`appendMany` (see [Transactions](/chronicle/clients/kotlin/guides/transactions/)) over calling `addEvent` directly; the transactional event sequence resolves the right `EventSequenceId` for you. `commit()` appends the staged events in the order they were staged. Consecutive events for the same event sequence, event source and options go in one atomic batch; each new event source starts a new batch. The batches are sent one after another, so a unit of work that spans several event sources is not atomic across them: an earlier batch stays appended when a later one is rejected. After `commit()`, `isSuccess` reflects whether every staged event was appended without a constraint violation, concurrency violation, or append error; `getConstraintViolations()`/`getConcurrencyViolations()`/ `getAppendErrors()` report the specifics, and `tryGetLastCommittedEventSequenceNumber()` returns the highest sequence number actually committed (`null` if nothing committed). `onCompleted` registers a callback invoked once, whether the unit of work ends via `commit()` or `rollback()` — it can be called multiple times to register several callbacks. *** ## IIdentityManagerService ```kotlin interface IIdentityManagerService { suspend fun rename(subject: String, name: String) } ``` Renames the human-readable name the kernel has stored for the identity identified by `subject`. This only updates the stored display name; it does not change the `Identity` objects your process is currently using — see [Correlation and identity](/chronicle/concepts/correlation-identity-causation/). *** ## INamespacesService ```kotlin interface INamespacesService { suspend fun ensure(namespaceName: String) suspend fun getAll(): List } ``` `ensure` creates a namespace if it doesn’t already exist (idempotent). `getAll` lists every namespace in the event store. *** ## IReactorsService ```kotlin interface IReactorsService { suspend fun register(reactor: Any): Job } ``` A handler takes the event first, and after that anything the client can supply for it — an `EventContext`, a read model resolved for the event’s event source, or whatever an `IReactorMethodArgumentResolver` claims. Handlers may suspend. `IReactorMiddleware` wraps every invocation. See [Artifact Registration](/chronicle/clients/kotlin/guides/artifact-registration/) for both. ### Being told about a replay Implement `ICanBeNotifiedAboutReplay` and declare `replayBegan` and/or `replayEnded`. Each takes a `ReplayContext` or nothing at all, and may suspend. Chronicle replays each event source independently, so the notifications arrive once per partition rather than once overall. ```kotlin data class ReplayContext( val observerId: String, val partition: String, val sequenceNumber: EventSequenceNumber ) ``` The kernel flags the first and last event of a replay rather than sending a separate signal, so `replayBegan` runs immediately before the first replayed event is handled and `replayEnded` immediately after the last. A replay that delivers no events produces no notification — there was no first event to flag. Implementing the interface and declaring neither method is rejected at registration: a marker with nothing behind it would silently do nothing. *** ## IFailedPartitions A handler that throws stops the event source it threw on and leaves every other one running. That keeps one bad event from halting the system, and it also means a stuck partition announces itself nowhere. `store.failedPartitions` is how an application finds out, and how an operator recovers once the cause is fixed. ```kotlin interface IFailedPartitions { suspend fun getFor(observerId: String): List suspend fun getFor(observerClass: KClass<*>): List suspend fun retry( observerId: String, partition: String, eventSequenceId: EventSequenceId = EventSequenceId.eventLog ) suspend fun retry(observerClass: KClass<*>, partition: String) } data class FailedPartition( val id: UUID, val observerId: String, val partition: String, val attempts: List ) { val lastAttempt: FailedPartitionAttempt? } data class FailedPartitionAttempt( val occurred: Instant, val sequenceNumber: EventSequenceNumber, val messages: List, val stackTrace: String ) ``` The overloads taking a `KClass` read the observer’s id — and, when retrying, the sequence it observes — off the class exactly as registration does, so an observer can be asked about by type rather than by remembering what its id came out as. `attempts` is the history of the problem, oldest first: `lastAttempt` is the one still standing in the way. Retrying an observer whose cause has not been fixed simply adds another attempt, so fix first and retry after. *** ## IObservers `store.observers` lists what the event store knows about its observers, and removes one whose declaring code is gone — a deleted read model and its projection, a removed reactor. The observer it registered stays behind, settles into `ObserverRunningState.Disconnected`, and keeps its records in the event store forever until something removes them. ```kotlin interface IObservers { suspend fun getAll(): List suspend fun remove(observerId: String): ObserverRemovalResult } data class ObserverInformation( val id: String, val eventSequenceId: EventSequenceId, val type: ObserverType, val runningState: ObserverRunningState, val lastHandledEventSequenceNumber: EventSequenceNumber, val nextEventSequenceNumber: EventSequenceNumber, val handledEventCount: Long ) enum class ObserverType { Unknown, Reactor, Projection, Reducer, External } enum class ObserverRunningState { Unknown, Active, Suspended, Replaying, Disconnected, Quarantined } data class ObserverRemovalResult( val outcome: ObserverRemovalOutcome, val blockingNamespace: String ) { val isRemoved: Boolean } enum class ObserverRemovalOutcome { Removed, ObserverNotFound, ObserverActive, ObserverSubscribed } ``` Removal covers the whole event store, not just the current namespace — an observer’s definition, and its projection definition where it has one, are store-level records shared by every namespace. `remove` refuses while the observer is running or still has a subscribed client in *any* namespace, and `blockingNamespace` names the one that blocked it, since the guard runs across all of them. Stop the declaring application first if the intent is to remove a live observer — there is no override. Read model data and sink containers are left untouched; only the observer’s own bookkeeping goes. *** ## IReducersService ```kotlin interface IReducersService { suspend fun register(reducer: Any): Job } ``` *** ## IReadModelsService ```kotlin interface IReadModelsService { suspend fun register(vararg readModelClasses: KClass<*>) suspend fun getInstanceByKey( readModelClass: KClass, key: String ): T? suspend fun getInstances( readModelClass: KClass, eventCount: Long? = null ): List suspend fun getSnapshotsById( readModelClass: KClass, key: String ): List> fun watch(readModelClass: KClass): Flow> suspend fun dehydrateSession( readModelClass: KClass<*>, key: String, sessionId: String ) suspend fun release(instance: T, subject: String? = null): T suspend fun releaseMany(instances: List): List val materialized: IMaterializedReadModels } ``` * `getInstanceByKey` returns the instance for one key, or `null` when there is none. For a read model that a reducer or an active projection keeps up to date, the kernel reads the stored copy, which is written after the events are appended. A call right after an append can therefore return `null` or an older state; see [Connection lifecycle](/chronicle/clients/kotlin/reference/connection-lifecycle/#reading-after-writing). A passive read model ([`@Passive`](/chronicle/clients/kotlin/reference/annotations/#passive), or a reducer with `isActive = false`) is computed from its events on each read instead, so it is current. * `getInstances` returns every instance of a read model. Without `eventCount` the kernel returns the stored copies, which are eventually consistent; with `eventCount` it replays that many events from the start instead, which can return incomplete results. A passive reducer’s model is folded in the client from its events. * `getSnapshotsById` returns the full history of intermediate states for one read model key, grouped by correlation id — each `ReadModelSnapshot` carries the deserialized `instance`, the `events` that produced it, and (when known) when they `occurred` and their `correlationId`. Unlike `getInstanceByKey`, which only returns the latest state, this is the way to inspect how a read model got to where it is. * `watch` returns a `Flow>` of live changes, deserialized straight into `T` — no polling, no manual JSON parsing. Each `ReadModelChangeset` carries the `modelKey`, the `changeType` (`Added`/`Modified`/`Removed`), the deserialized `readModel` (`null` when `removed`), and the triggering event’s `eventSequenceNumber`/`occurred`/ `correlationId` when known. * `release`/`releaseMany` decrypt `@Pii`-annotated properties on one or a batch of read model instances. The compliance subject defaults to each instance’s `id` property if present; pass `subject` explicitly to `release` when there is no `id` property or it isn’t the compliance subject. *** ## IReadModelReactors ```kotlin interface IReadModelReactors { fun register(reactor: IReadModelReactor): Job fun stop() } ``` Construct the implementation with the read models to watch through and the event log any side-effect events are appended to: `ReadModelReactors(store.readModels, store.eventLog)`. It is not reached through `IEventStore`. * `IReadModelReactor` is a marker interface. Dispatch is by convention: a method named `added`, `modified` or `removed` (matched case-insensitively) handles the corresponding `ReadModelChangeType`. Its first parameter is the read model — a single instance or a `List` of them — and that type selects which read model is watched. An optional second parameter of type `ReadModelChangeset` carries the change metadata. * A removal never carries an instance, so a Kotlin `removed` handler must declare its read model parameter nullable. Handlers that cannot be dispatched to are rejected at registration with `InvalidHandlerSignature`. * A handler may return an event, an `EventForEventSourceId`, or a `List` of either, to be appended as a side effect — bare events use the changed instance’s key as the event source id. Return values that are not event types are ignored. * `register` is not a suspending function and returns the `Job` backing that reactor’s subscriptions; `stop` cancels every reactor registered through the instance. See the [read model reactors guide](/chronicle/clients/kotlin/guides/read-model-reactors/) for worked Kotlin and Java examples. *** ## IProjectionsService ```kotlin interface IProjectionsService { suspend fun register(vararg projections: Any) suspend fun query( declaration: String, eventSequenceId: EventSequenceId = EventSequenceId.eventLog ): ProjectionQueryResult } ``` ### Ad-hoc queries A registered projection is the right answer when a read model is part of the system: the kernel keeps it up to date and the client hands you a type. `query` is for the questions that are not part of the system — a one-off question of the event log during an incident, a report nobody wants to deploy a projection for, a declaration being written in an editor and tried out as it is typed. Nothing is registered, nothing is persisted, and nothing observes afterwards. The kernel projects over the sequence and hands back the result. ```kotlin val result = store.projections.query( """ from EmployeeHired set name to firstName """ ) when (result) { is ProjectionQueryResult.Projected -> result.instancesOf(EmployeeName::class).forEach(::println) is ProjectionQueryResult.Invalid -> result.errors.forEach(::println) } ``` A declaration is a piece of text, so the first thing that can go wrong is that the kernel cannot parse it. That is an ordinary outcome of asking a question in a language rather than an exception, so it comes back as `Invalid` carrying a line and column per error — which is what you show whoever wrote it. An ad-hoc declaration has no registered read model type, so `entries` is raw JSON and `instancesOf` deserializes it into whatever shape the declaration produced. From Java, use `ProjectionsServiceJavaBridge.query(...)`. *** ## IConstraintsService ```kotlin interface IConstraintsService { suspend fun register(vararg constraints: Any) } ``` `IConstraintBuilder`, passed into `IConstraint.define`, can additionally scope every constraint added through it — see [Constraint scoping](/chronicle/clients/kotlin/guides/constraints/). *** ## IEventTypesService ```kotlin interface IEventTypesService { suspend fun register(vararg eventClasses: KClass<*>) suspend fun registerSingle(eventClass: KClass<*>) suspend fun getAllGenerationsForEventType( eventTypeId: String ): List fun getRegisteredEventTypes(): List } ``` `register`/`registerSingle` accept both plain `@EventType`-annotated classes and [`IEventTypeMigration`](/chronicle/clients/kotlin/guides/event-evolution/) classes, merging them into one registration per event type id, schema included (generated from the latest generation’s class — see [Annotations](/chronicle/clients/kotlin/reference/annotations/)). `getRegisteredEventTypes` returns every event type registered through this instance so far; it’s what `eventStoreSubscriptions.subscribe` falls back to when a subscription isn’t narrowed to specific event types. *** ## IEventSeedingService ```kotlin interface IEventSeedingService { suspend fun seed(vararg seeders: Any) } ``` See [Seeding](/chronicle/clients/kotlin/guides/seeding/) for `IEventSeedingBuilder`, including namespace-scoped seed data via `forNamespace`. *** ## IComplianceService ```kotlin interface IComplianceService { suspend fun release( subject: String, schema: String, payload: String ): String suspend fun deleteEncryptionKey(identifier: String) } ``` `deleteEncryptionKey` deletes the encryption key for a compliance subject — a “right to be forgotten” erasure that leaves existing encrypted PII content permanently undecryptable, without rewriting the events themselves. Compare with [`redact`/`redactForEventSource`](#redacting-events), which instead rewrites event content directly. *** ## Java interop Every service method above is a Kotlin `suspend` function, which Java cannot call directly — a `suspend fun` carries a hidden continuation on the JVM. ### Start here: the blocking client `io.cratis.chronicle.java` carries a blocking view of the client. It is what a Java application should use, and it reads the same as the Kotlin one: ```java var client = BlockingChronicleClient.connect(ChronicleOptions.development()); var eventStore = client.getEventStore("ChronicleConsole"); eventStore.getEventLog().append("some-event-source", new TestEvent("Hello world!")); ``` Artifacts still register themselves, and the append waits for that to finish — so there is nothing to declare and nothing to sequence. * `BlockingChronicleClient` — `connect`, `getEventStore(s)`, `dispose`. Closeable. * `BlockingEventStore` — the event log and other sequences, transactional appends, read models, reactors, units of work, and registration. * `BlockingEventSequence` — `append`, `appendMany`, `hasEventsFor`, sequence numbers, reading events back, `redact`. * `BlockingTransactionalEventSequence` — `append` and `appendMany`, staged against the current unit of work. * `BlockingReadModels` — `getInstanceByKey` and `register`, by plain `Class`. * `BlockingReactors` — `register`. * `BlockingUnitOfWork` — `commit`, `rollback`. Closeable, rolling back unless it was committed. Every call blocks until the kernel answers, which is what a `main`, a controller method or a scheduled job wants. Do not call them from inside a coroutine — Kotlin should use the suspending API directly. `unwrap()` on any of them returns the suspending object underneath. A unit of work reads as try-with-resources, so a throw needs no `catch`: ```java var eventStore = new BlockingEventStore(store); try (var unitOfWork = eventStore.beginUnitOfWork()) { var transactional = eventStore.getTransactional(); transactional.append("order-123", new OrderPlaced(99.95)); transactional.append("inventory-widget", new InventoryReserved("widget", 1)); // Two event sources, so two batches: not atomic across them. unitOfWork.commit(); } ``` ### The lower-level bridges Underneath, the same package provides static bridges — each takes the service (or sequence) as its first argument. Reach for these for the corners the blocking client does not wrap. A few Kotlin value classes (`EventSequenceNumber`, `EventSequenceId`, `EventTypeId`, `RedactionReason`, `CausationType`) have mangled constructors on the JVM ABI, so anything Java-facing takes and returns plain `long`/`String` instead. Reading one back works — `getValue()` is the one accessor Kotlin leaves unmangled — but Java can never construct one, which is why none appears in a Java-facing signature. `Causation.of(timestamp, type)` exists for the same reason. * `EventLogJavaBridge` — `append`, `appendMany`, `hasEventsFor`, `getForEventSourceIdAndEventTypes`, `getFromSequenceNumber`, `getTailSequenceNumber`, `getNextSequenceNumber`, `completeStream`, `redact`, `redactForEventSource`, `watchAppendOperations` * `TransactionalEventSequenceJavaBridge` — `append`, `appendMany` * `ConcurrencyScopeBuilderJavaBridge` — `withSequenceNumber` * `EventTypesServiceJavaBridge` — `register`, `registerSingle`, `getAllGenerationsForEventType` * `ReadModelsJavaBridge` — `register`, `getInstanceByKey`, `getInstances`, `getSnapshotsById`, `watch`, `dehydrateSession`, `release`, `releaseMany`, `getMaterializedInstances` * `ReactorsServiceJavaBridge` — `register` * `ReducersServiceJavaBridge` — `register` * `ProjectionsServiceJavaBridge` — `register` * `ConstraintsServiceJavaBridge` — `register` * `EventSeedingServiceJavaBridge` — `seed` * `EventSeedingBuilderJavaBridge` / `EventSeedingScopeBuilderJavaBridge` — `forEventType` * `NamespacesServiceJavaBridge` — `ensure`, `getAll` * `IdentityManagerServiceJavaBridge` — `rename` * `UnitOfWorkJavaBridge` — `commit`, `rollback` * `ComplianceServiceJavaBridge` — `release`, `deleteEncryptionKey` `ReadModelReactors` needs no bridge — its constructor takes plain types and neither `register` nor `stop` is a suspending function, so Java calls both directly. ```java import io.cratis.chronicle.java.EventLogJavaBridge; var result = EventLogJavaBridge.append( store.getEventLog(), "emp-001", new EmployeeHired("emp-001", "Jane", "Smith", "Engineering"), null); ``` `EventSequenceNumber` is a Kotlin value class, so an `AppendResult` has no ordinary getter for it on the JVM. Read it with `EventLogJavaBridge.getSequenceNumber(result)`. The same applies to `getTailSequenceNumber`/`getNextSequenceNumber` (they return `long` directly) and to `redact`/`redactForEventSource` (they take a `long` sequence number and a plain `String` reason rather than `EventSequenceNumber`/`RedactionReason`). `ConstraintBuilderJavaBridge`, `UniqueConstraintBuilderJavaBridge`, `ProjectionBuilderJavaBridge`, and `CausationManagerJavaBridge` cover the builder APIs that take a `KClass` in Kotlin, accepting a `Class` instead. # Geospatial types > Point, LineString and Polygon in the JVM client, and how they serialize as GeoJSON for geospatial indexing. Chronicle recognizes a value as geospatial by its [GeoJSON](https://geojson.org) shape. The client ships three types that serialize to that shape, so the sink can index and query them geospatially. A hand-rolled coordinate class serializes as an ordinary nested object and gets none of that. They live in `io.cratis.chronicle.geospatial` and are usable on any event, read model, or reducer state. ## Types | Type | Constructor | GeoJSON `type` | Schema `format` | | --- | --- | --- | --- | | `Point` | `Point(longitude, latitude)` | `Point` | `point` | | `LineString` | `LineString(coordinates)` | `LineString` | `linestring` | | `Polygon` | `Polygon(shell, holes)` | `Polygon` | `polygon` | | `LinearRing` | `LinearRing(coordinates)` | *(a polygon ring)* | — | ```kotlin data class Point(val longitude: Double, val latitude: Double) data class LineString(val coordinates: List) data class LinearRing(val coordinates: List) data class Polygon( val shell: LinearRing, val holes: List = emptyList() ) ``` Longitude comes first, matching GeoJSON coordinate order — not the “latitude, longitude” order used in everyday speech. `LineString` and `LinearRing` also take loose points (`LineString(a, b, c)`), which is what Java uses in place of a list literal. `Polygon` is `@JvmOverloads`, so `new Polygon(shell)` works from Java for a polygon with no holes. ## Using them on an event ```kotlin import io.cratis.chronicle.events.EventType import io.cratis.chronicle.geospatial.Point @EventType data class WarehouseInspected( val warehouseId: String = "", val inspectedAt: Point = Point(0.0, 0.0) ) ``` ```java import io.cratis.chronicle.events.EventType; import io.cratis.chronicle.geospatial.Point; @EventType public record WarehouseInspected(String warehouseId, Point inspectedAt) {} ``` Appending is unchanged — the client serializes the point as GeoJSON on the way out: ```kotlin import io.cratis.chronicle.geospatial.Point store.eventLog.append( eventSourceId = "warehouse-1", event = WarehouseInspected("warehouse-1", Point(10.75, 59.91)) ) ``` The event content on the wire: ```json { "warehouseId": "warehouse-1", "inspectedAt": { "type": "Point", "coordinates": [10.75, 59.91] } } ``` ## Building a polygon A polygon’s shell must be closed — its last point repeats its first — and needs at least four points. Holes follow the same rule. ```kotlin import io.cratis.chronicle.geospatial.LinearRing import io.cratis.chronicle.geospatial.Point import io.cratis.chronicle.geospatial.Polygon val deliveryZone = Polygon( shell = LinearRing( Point(10.70, 59.90), Point(10.80, 59.90), Point(10.80, 59.95), Point(10.70, 59.90) ) ) ``` GeoJSON stores the shell and the holes in one flat array of rings, with the shell first. Reading a polygon back takes the first ring as the shell and the rest as holes. ## Validation on read Deserialization rejects geometries the sink would refuse, so a malformed value fails at the client rather than deep in the kernel. | Geometry | Rejected when | | --- | --- | | `Point` | `coordinates` is not `[longitude, latitude]` | | `LineString` | fewer than 2 points | | `Polygon` | it carries no rings at all | | `LinearRing` | fewer than 4 points, or it does not close | | all | the declared `type` is not the expected one | Construction is not validated — only what comes off the wire is. ## Serializing yourself `io.cratis.chronicle.json.ChronicleGson.chronicleGson` is the `Gson` instance the client serializes event content and read model state with. Use it when you need to produce the same JSON the client would. ```kotlin import io.cratis.chronicle.geospatial.Point import io.cratis.chronicle.json.chronicleGson val json = chronicleGson.toJson(Point(10.75, 59.91)) ``` ```java import io.cratis.chronicle.geospatial.Point; import io.cratis.chronicle.json.ChronicleGson; String json = ChronicleGson.chronicleGson.toJson(new Point(10.75, 59.91)); ``` # TypeScript client > Use Chronicle from Node.js and TypeScript with the @cratis/chronicle package — setup, connection, and TypeScript-specific APIs. `@cratis/chronicle` lets a Node.js application written in TypeScript append events to Chronicle, build read models from them, and react to them, using decorated classes instead of hand-written gRPC calls. It builds on the [`@cratis/chronicle.contracts`](https://www.npmjs.com/package/@cratis/chronicle.contracts) gRPC contracts package. This section covers what is specific to TypeScript: installation, TypeScript configuration, decorators, connection setup, and TypeScript-only APIs. Chronicle concepts and workflows are explained once in the shared Chronicle docs, which show TypeScript examples in their TypeScript tabs. ## Start here [Get started with the TypeScript client](/chronicle/clients/typescript/getting-started/) takes you from an empty folder to a program that appends events and reads the projected read model back. It also covers Node.js and TypeScript requirements, decorator modes, and the most common setup errors. Then read [Connect to Chronicle](/chronicle/clients/typescript/connecting/) before you point the client at anything other than a local development kernel. The client skips TLS certificate validation by default, and it retries a failing connection silently. ## Shared Chronicle topics * [Get started](/chronicle/get-started/) * [Events and event logs](/chronicle/events/) * [Appending events](/chronicle/events/appending/) * [Read models](/chronicle/read-models/) * [Projections](/chronicle/projections/) * [Reactors](/chronicle/reactors/) * [Reducers](/chronicle/reducers/) * [Constraints](/chronicle/constraints/) * [Event seeding](/chronicle/event-seeding/) * [Compliance](/chronicle/compliance/) * [Transactions and unit of work](/chronicle/events/transactions/) * [Event evolution](/chronicle/understanding-event-evolution/) The TypeScript examples on those pages are compiled with legacy decorators (`experimentalDecorators`). Examples that declare event properties as constructor parameters, such as `constructor(readonly name: string)`, need legacy decorators. With standard decorators, declare the properties with `@field(Type)` instead, as described in [Decorator mode and schema types](/chronicle/clients/typescript/getting-started/#decorator-mode-and-schema-types). ## TypeScript-specific pages * [Connect to Chronicle](/chronicle/clients/typescript/connecting/) — connection strings, credentials, TLS, reconnection, compatibility, and shutdown * [Preserve existing append routes](/chronicle/clients/typescript/migrate-append-routing/) — keep writing to existing streams after the append-routing change * [Observers](/chronicle/clients/typescript/observers/) — listing observers and removing one whose declaring code is gone * [Sinks](/chronicle/clients/typescript/sinks/) — choose where read models are stored * [Jobs](/chronicle/clients/typescript/jobs/) — inspect and control Chronicle jobs * [Constraints](/chronicle/clients/typescript/constraints/) — unique event properties and event types * [Webhooks](/chronicle/clients/typescript/webhooks/) — register webhooks in code or with decorators * [External Services](/chronicle/clients/typescript/external-services/) — register HTTP and database services in code * [Seeding](/chronicle/clients/typescript/seeding/) — seed events with `@seeder` classes * [Identity](/chronicle/clients/typescript/identity/), [Causation](/chronicle/clients/typescript/auditing/), and [Correlation](/chronicle/clients/typescript/correlation/) — the metadata the client attaches to every append * [Failed Partitions](/chronicle/clients/typescript/failed-partitions/) — find observers that stopped processing an event source ## Known limitations * `AppendResult.waitForCompletion()` can time out when an observer on the event sequence does not handle the appended event. The kernel waits for every observer on the sequence ([Cratis/Chronicle#4132](https://github.com/Cratis/Chronicle/issues/4132)). * Earlier client versions registered standard-decorated property-only read models only after construction. This client discovers exported models before construction; compiled JavaScript without discovery patterns needs a class-level `@fromEvent(...)` or explicit registration. Store creation emits a diagnostic warning once per process for unresolved mappings. See [Artifact discovery](/chronicle/clients/typescript/getting-started/#artifact-discovery). # Auditing — causation > Add application-specific links to the causation chain the TypeScript client sends with every append. See [Correlation, identity, and causation](/chronicle/concepts/correlation-identity-causation/) for what causation means and why Chronicle tracks it. This page covers the TypeScript-specific API in depth. The causation chain is stored with every event and enables full audit-trail tracing. ## Concepts | Type | Role | | --- | --- | | `CausationType` | A named string wrapper identifying the *kind* of operation that caused something. | | `Causation` | A single node in the chain — a timestamp, a `CausationType`, and a property bag. | | `ICausationManager` | Interface for reading and mutating the chain in the current call context. | | `CausationManager` | Default `AsyncLocalStorage`-backed implementation. | ## `CausationType` ```typescript import { CausationType } from '@cratis/chronicle'; // Built-in types CausationType.root // 'Root' — the process-level root CausationType.unknown // 'Unknown' CausationType.appendEvent // 'TypeScriptClient.Append' CausationType.appendManyEvents // 'TypeScriptClient.AppendMany' // Custom types const myType = new CausationType('MyApp.CreateEmployee'); ``` ## `Causation` ```typescript import { Causation, CausationType } from '@cratis/chronicle'; const entry = new Causation( new Date(), // occurred new CausationType('MyApp.CreateEmployee'), // type { employeeId: '123' } // properties (string→string map) ); // Factory for a placeholder const placeholder = Causation.unknown(); ``` ## Managing the causation chain The module exports a singleton `causationManager` that uses Node.js [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html#class-asynclocalstorage) so the chain is scoped automatically to the active async context. ### Reading the current chain ```typescript import { causationManager } from '@cratis/chronicle'; const chain = causationManager.getCurrentChain(); // chain[0] is always the root causation // chain[n] are additional entries added in this context ``` ### Adding causation Call `add()` before performing an operation to push a new entry onto the chain: This excerpt assumes a `store`, an `eventSourceId`, and an `EmployeeHired` event type. ```typescript import { causationManager, CausationType } from '@cratis/chronicle'; causationManager.add( new CausationType('MyApp.CreateEmployee'), { correlatedCommand: 'HireEmployee', tenantId: 'acme' } ); // Now append an event — the event will carry this entry in its Causation chain await store.eventLog.append(eventSourceId, new EmployeeHired('Jane', 'Doe')); ``` ### Defining the process root By default the root causation is a bare `CausationType.root` entry. Call `defineRoot()` once at start-up to attach application-specific properties: ```typescript import { causationManager } from '@cratis/chronicle'; causationManager.defineRoot({ application: 'hr-service', version: '2.1.0' }); ``` ### Command-handler middleware example ```typescript import { causationManager, CausationType } from '@cratis/chronicle'; async function handleCommand(commandName: string, handler: () => Promise) { await causationManager.run(new CausationType(`MyApp.Command.${commandName}`), {}, handler); } ``` `run(type, properties, callback)` restores the parent chain even when the callback awaits or throws. `run(callback)` isolates the existing chain without adding a link. Prefer these for concurrent or nested operations; `add()` changes the ambient chain of the current context and remains for legacy callers. Each append adds its own link only to that append’s outgoing chain, so sibling appends do not inherit one another’s links. ## How causation flows into events When you call `eventLog.append()` or `eventLog.appendMany()`, the Chronicle client automatically reads `causationManager.getCurrentChain()` and sends the full chain as the `Causation` field of the gRPC request. You do not need to pass it manually. # Compliance > Compliance and PII handling are documented in the shared Chronicle docs, with TypeScript examples. Compliance and PII handling are shared Chronicle topics. The shared docs own the concepts, storage behavior, and client-tabbed examples. * [Compliance](/chronicle/compliance/) * [PII](/chronicle/compliance/pii/) * [Compliance in read models](/chronicle/compliance/read-models/) * [TypeScript client setup](/chronicle/clients/typescript/getting-started/) # PII > Release PII in raw stored read model documents with the TypeScript client. PII classification and release behavior are shared Chronicle compliance topics. * [PII](/chronicle/compliance/pii/) * [Compliance](/chronicle/compliance/) * [TypeScript client setup](/chronicle/clients/typescript/getting-started/) ## Select the subject for event PII When an event source identifies a registration or transaction rather than a person, mark the event’s person identifier with `@subject()` (imported from `@cratis/chronicle/compliance`). Chronicle uses the resolved subject’s encryption key to protect the event’s PII. Delete that subject’s key to erase their PII. For events appended before this fix, also check the event source’s key as explained in the upgrade note below. Single append and both `appendMany` overloads select the subject in this order: 1. The explicit subject: a batch entry’s `subject` overrides the call’s `AppendOptions.subject`. 2. The value of the event’s first property marked `@subject()`. 3. The event source identifier. Null or undefined annotation values fall back to the event source. String values, including empty and whitespace-only strings, are sent as-is. Chronicle falls back to the event source identifier for both the stored context subject and the PII encryption key when the selected subject is empty or whitespace-only. An explicit empty string option still blocks the annotation, so that event uses the event source’s key. Concept values and other non-string values use `toString()`; a null or empty result falls back. There is no event `id` property convention. Transactional appends resolve the annotation when the unit of work commits. `EventScenario` uses the same precedence for single and batch appends and for its given-event seeds; it does not emulate PII encryption or erasure. Kernel event seeding uses a separate contract that has no subject field in Chronicle 19.26.2, so it cannot select an annotated subject. Upgrade note: this fixes previously ignored event annotations, so newly appended events carrying `@subject()` can now use a different encryption key. Events appended before this fix with `@subject()` but no explicit subject were encrypted under the event source identifier’s key. Existing stored events are not re-encrypted. To erase that older PII, you must also delete the event source identifier’s key (or use the existing erase APIs for that identifier), in addition to the subject’s key. Deleting the event source’s key also erases other PII protected by that same key. If your erasure process deletes the event source identifier’s key, events appended after upgrading that carry `@subject()` are no longer protected by that key; erase the subject’s key instead. ## Release a stored document If you read a read model directly from its store, use `readModels.releaseDocument(ReadModelType, document)` to release its PII. Supply the discovered read model class with its runtime field types and PII metadata, and the raw JSON document with its stored `__subject` and `__subjects` fields. Do not convert the document to a class instance first: that can discard the subject metadata needed for joined data. For example, given your configured `store`, discovered `AccountSummary` read model, and a raw stored `document`: ```typescript const released = await store.readModels.releaseDocument(AccountSummary, document); ``` The result is a new plain `Record`, not an `AccountSummary` instance. Only schema-declared top-level properties are returned; storage-only fields, including `__subject` and `__subjects`, are removed. The input document and its nested values are not mutated. Subject selection follows the stored metadata: * `__subjects` maps **top-level property names** to non-empty subject strings. Each mapping applies to the property’s entire subtree, including arrays. Null or empty mapping values are ignored, so those properties use the default subject. A non-object `__subjects` value is treated as an empty map. Dotted paths such as `contact.phone` are not supported. * `__subject` is a non-empty string used for properties without a mapping. Null or an empty string means there is no default subject. Whitespace-only subject strings are valid and passed through unchanged. * Properties with neither subject are copied unchanged, without release. There is no fallback to `id` or `@subject()`. The client makes one release call per distinct subject, using only that subject’s properties and their schemas. If a call reports `HasError`, fails in transport, or returns malformed, missing, or conflicting properties, the whole operation rejects. Non-string subjects other than null, reserved subject-map names, and mappings for document properties absent from the schema also reject. Mappings for properties absent from the document are ignored after validating their subject values. You never receive a partially merged document. Errors omit kernel and transport details because they can contain PII. Chronicle 19.26.2 can replace individual values with empty values after a per-property decryption failure while still returning `HasError: false`. The client cannot detect these unreported failures and does not guess from an empty value. Track this limitation in [Chronicle issue 4480](https://github.com/Cratis/Chronicle/issues/4480). For a typed instance with one subject, keep using `readModels.release(type, instance)` or `releaseMany(type, instances)`; those APIs are unchanged. # Compliance in read models > Read-model compliance is documented in the shared Chronicle compliance docs. Read-model compliance is documented in the shared Chronicle compliance section so all clients use the same rules. * [Compliance in read models](/chronicle/compliance/read-models/) * [Read models](/chronicle/read-models/) * [TypeScript client setup](/chronicle/clients/typescript/getting-started/) # Composite keys > Build TypeScript projection keys from event content, context, event source identifiers and constants. When an event property alone does not identify a read-model instance, combine it with context or a fixed category. Each part of a TypeScript composite key uses the same set-expression builder as a projection property. ## Configure the parts Inside a declarative projection, use `usingCompositeKey()` for the instance key. `usingParentCompositeKey()` emits a child’s parent key, subject to the [kernel limitations below](#parent-and-join-keys). The following excerpt assumes the `CompositeRecordChanged`, `CompositeRecordKey` and read-model types in the [complete expression-parts example](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/client-snippets/projections/declarative/composite-keys/expression-parts.md): ```typescript builder.from(CompositeRecordChanged, from => from .usingCompositeKey(key => key .set(target => target.orderId, event => event.orderId) .set(target => target.subject).toEventContextProperty('subject') .set(target => target.sourceId).toEventSourceId() .set(target => target.category).toValue('orders'))); ``` The event’s order id, subject and source id determine the instance, while `orders` supplies a fixed category. Repeated events with those same values update the same instance; changing the subject or source id selects another instance. | Part source | Configuration after `set(target)` | Wire expression | | --- | --- | --- | | Event property | `.to(event => event.orderId)` | `orderId` | | Context property | `.toEventContextProperty('subject')` | `$eventContext(Subject)` | | Event source id | `.toEventSourceId()` | `$eventSourceId` | | Constant | `.toValue('orders')` | `$value(orders)` | The existing two-argument `set(target, eventAccessor)` remains available and can be mixed with the new form. Every `to` method returns the composite builder for chaining. Complete each one-argument `set` with a `to` method before building the projection; an unfinished part throws instead of emitting an invalid key. `toValue` requires the selected property’s TypeScript type: a numeric part cannot take a string constant. Setting the same target part twice now throws immediately, including when its first `set` is unfinished. This surfaces an invalid configuration earlier, matching .NET’s duplicate-property check; the kernel’s composite parser also rejects duplicates. Context paths use TypeScript spelling, including `causedBy.subject`; the client emits CLR-cased paths such as `$eventContext(CausedBy.Subject)`. The syntax uses parentheses, not `$eventContext.Subject`. Constants use the same validation and serialization as other `toValue` mappings, including `$null` for null. Characters the kernel cannot parse in a constant, such as commas, are rejected. The example emits: ```text $composite(orderId=orderId,subject=$eventContext(Subject),sourceId=$eventSourceId,category=$value(orders)) ``` ## Parent and join keys `usingParentCompositeKey` accepts the same expressions as the .NET client, but Chronicle 19.26.2 does not generally resolve composite parents. With a root `From` subscription and a parent key combining content, context and source id **without a constant**, the first child can be deferred even though its parent exists. The kernel searches for a parent event using the composite object’s string representation, then falls back to looking for an existing child through `items.id`; neither locates that first child’s parent. [Chronicle #551 tracks composite parent resolution](https://github.com/Cratis/Chronicle/issues/551). The client does not reject these definitions, preserving .NET parity. A `$value(...)` part changes that path in 19.26.2: the kernel’s unanchored constant-expression matcher classifies the entire composite parent expression as constant and bypasses parent-event lookup. The constant-bearing parent fixture succeeds through this shortcut, not through general composite-parent support. Do not rely on adding a constant as a supported workaround. Chronicle 19.26.2 ignores custom **root** join keys: backfill and live updates still match the join event’s source id ([Chronicle #4165](https://github.com/Cratis/Chronicle/issues/4165)). **Child** joins do resolve custom composite key expressions. Chronicle 19.26.2 uses the resolved child-join key as both the root document key and the child identifier, so a child join only updates children whose root has the same identifier ([Chronicle #4530](https://github.com/Cratis/Chronicle/issues/4530)). The oracle fixture deliberately uses matching composite root and child identifiers. Children are created through the `$value` parent-key shortcut; this fixture isolates child-join key evaluation, not parent resolution. A join’s parent-key methods have no wire effect because `JoinDefinition` has no parent-key field. The in-memory sink replaces the matched root state with the join changes. This is a Chronicle in-memory sink bug: root properties and sibling children are dropped ([Chronicle #4531](https://github.com/Cratis/Chronicle/issues/4531)), so in-memory results can differ from persistent storage. ## Testing boundary The in-process `ReadModelScenario` evaluator still rejects composite instance, parent and join keys before replay. Use kernel-backed tests for these projections. The packaged projection oracle covers root keys, the constant-bearing parent shortcut, constant-free parent deferral, ignored root join keys and resolved child join keys. The deferral fixture captures an attempted `AddFuture` through the oracle’s fail-closed futures guard, not a kernel exception or successful future storage. These fixtures do not prove persistent-sink behavior. For the shared projection concepts and other clients, continue with the [Chronicle composite keys guide](/chronicle/projections/declarative/composite-keys/). # Connect to Chronicle > Configure the TypeScript client's connection string, credentials, and TLS, and understand how it connects, reconnects, checks compatibility, and shuts down. The [getting started guide](/chronicle/clients/typescript/getting-started/) connects to a local development kernel with `ChronicleOptions.development()`. This page covers everything else: connecting to a real server, securing the connection, and knowing what the client does when the network or the kernel misbehaves. ## Connect with a connection string ```typescript import { ChronicleClient, ChronicleOptions } from '@cratis/chronicle'; const options = ChronicleOptions.fromConnectionString(process.env.CHRONICLE_CONNECTION!); const client = new ChronicleClient(options); const store = await client.getEventStore('Library'); ``` Keep the connection string, which carries credentials, in configuration or a secret store rather than in source code. Creating a `ChronicleClient` does not connect. The first `getEventStore(...)` or `getEventStores()` call connects, and later calls reuse the connection. ## Connection string reference ```text chronicle://[:@][:][,[:]...][/?