---
title: Test events, reactors and read models without a kernel
editUrl: https://github.com/Cratis/Chronicle.TypeScript/edit/main/Documentation/testing.md
---

## EventScenario: fixture-backed appends

`EventScenario` is a scenario-local event sequence for single and batch accepted appends. No kernel, storage, or observers are started. The scenario uses the production constraint compiler, then evaluates only the fixture-backed subset below. For selected event types with default constraint discovery, it compiles globally discovered fluent constraints and the union of globally discovered and selected event constructors (deduplicated by constructor), then keeps definitions referencing a selected event type (as constrained or removal event). Selected decorators still apply when the discovery registry was cleared. Different constructors with the same event type ID reject with `UnsupportedEventSequenceOperation` when either carries constraint decorators; otherwise the selected constructor is used. Incomplete and unsupported definitions also reject rather than being approximated. Supply an isolated event catalog and explicitly disable constraints only when the behavior under test does not depend on them:

```typescript
import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle';
import { EventScenario } from '@cratis/chronicle/testing';

class MessageRecorded {
    @field(String) label: string;
    constructor(label: string) { this.label = label; }
}
eventType('MessageRecorded')(MessageRecorded);

const scenario = new EventScenario({
    artifacts: { eventTypes: [MessageRecorded] },
    constraints: 'disabled'
});
await scenario.given.forEventSource('message-1').events(new MessageRecorded('seed'));
const result = await scenario.when.forEventSource('message-2').event(new MessageRecorded('act'));
// result.isSuccess === true; scenario.results contains the act-phase result only.
const history = scenario.appendedEvents; // Serialized, independent snapshots of setup and act.
const events = await scenario.eventSequence.getFromSequenceNumber(result.sequenceNumber);
```

For an unscoped unique string property, include the decorated event type in the catalog and leave constraints enabled. Fixture-backed constrained values include digits, punctuation, empty or padded strings, and `é`; a schema-backed boolean is also supported. This is narrower than the kernel's general conversion rules:

```typescript
import { field } from '@cratis/fundamentals';
import { eventType, unique } from '@cratis/chronicle';
import { EventScenario } from '@cratis/chronicle/testing';

class SubscriberRegistered {
    @field(String) @unique('SubscriberEmail', 'Already used: {PropertyValue}') email: string;
    constructor(email: string) { this.email = email; }
}
eventType('SubscriberRegistered')(SubscriberRegistered);

const uniqueScenario = new EventScenario({ artifacts: { eventTypes: [SubscriberRegistered] } });
await uniqueScenario.given.forEventSource('first').events(new SubscriberRegistered('alice'));
const rejected = await uniqueScenario.when.forEventSource('second').event(new SubscriberRegistered('alice'));
// rejected.isSuccess === false; rejected.constraintViolations[0].message === 'Already used: alice'.
```

You can use your application's concept-typed fields without replacing them with primitives in tests. Declare `@field(AuthorName)` on the event property and `static readonly valueType = String` on an `AuthorName extends ConceptAs<string>` concept. Use `Boolean`, `Number`, `Guid` or `Date` for the corresponding concept value type. Both legacy and standard decorators use the connected client's schema generation and `JsonSerializer`: history, reactor deliveries and observing reducers receive serialized primitives, not concept wrappers. Non-constrained numeric, Guid, date/date-time and object fields are accepted, including typed nested objects. They do not need unique-key comparison semantics. Projection mappings still have their own limits: supported scalars project normally; object targets remain rejected.

Unique keys have a narrower boundary. String and boolean concepts retain their existing limits. Guid and numeric concepts support the isolated, single-property cases below. Date/date-time and object keys still throw `UnsupportedEventSequenceOperation` naming `artifacts.constraints`; disabling constraints explicitly accepts those payloads but does not test their uniqueness.

`scenario.eventLog` is the same sequence when the sequence ID is `event-log`. `scenario.then` is a non-callable assertion view with `results` and `appendedEvents`; use ordinary assertions on it. Direct calls to `scenario.eventSequence.append` and `appendMany` also enter `results`. `given.forEventSource(id).events(...events)` performs sequential **single** appends (including zero events), matching .NET setup; setup results are excluded. The scenario checks every setup event first: if any is rejected, it throws and commits none of that call's events. In .NET, setup ignores each append result, so a rejected event is silently skipped and other events before and after it are still appended. `when.forEventSource(id).event(event)` is the single-event act. In TypeScript, plural `when...events(...)` performs **one atomic batch**, including a one-event call, and returns one result per event. This deliberately differs from .NET's `When.Events`, which appends sequentially and returns one result. Empty batches reject. No append runs projections automatically; `ReadModelScenario` remains independent.

```typescript
const results = await scenario.when.forEventSource('message-2').events(
    new MessageRecorded('first'), new MessageRecorded('second'));
const mixed = await scenario.appendMany([
    { eventSourceId: 'message-1', event: new MessageRecorded('other') },
    { eventSourceId: 'message-2', event: new MessageRecorded('more'), subject: 'message-2' }
]);
// Both calls append atomically, in input order. mixed and results each contain per-event AppendResult values.
```

For the mixed-source overload, per-entry source/stream routing, subject, occurrence and tags override or combine with shared `AppendOptions` as in the production client: the scenario calls production `prepareBatchAppend`. Nonempty route and subject strings contain only ASCII letters, digits, `_` and `-`. Empty route strings resolve to the default dimension, including an empty per-entry value overriding a shared route; empty subjects remain rejected. Tags that JavaScript `trim()` reduces to an empty string are removed by production `mergeTags` before validation. Every other tag must contain only ASCII letters, digits, `_` and `-`; otherwise the append is rejected. Custom GUIDs must be valid. Shared or per-entry occurrence dates must be within UTC years 1–9999. An omitted route resolves to `Default`/`All`/`Default`; a separate batch oracle case sends omitted and empty route fields directly to the pinned kernel to verify that resolution. Read filters support source, type and route combinations within that domain; as the kernel fixture demonstrates, `Default` source type, `All` stream type and `Default` stream ID are non-narrowing filters (not exact-route filters). `appendOperations` is the same hot client-side notification stream as production: subscribe before appending; single appends, including each setup event from `given.events`, publish a one-entry array; `appendMany` and plural `when.events` publish one array with an entry per input event. Notifications carry the client-side input event, not a persisted-event snapshot.

| Operation | Basic EventScenario boundary |
| --- | --- |
| Single and atomic batch append (both overloads), sequential multi-event setup, plural batch action, global zero-based sequence allocation, `hasEventsFor`, next/tail (including type and route filters), source/type/route reads, inclusive reads from sequence (including type filters), append notifications | Supported for registered generation-1 events with unclassified string, boolean, numeric, Guid, date/date-time and object fields (including scalar concepts) and matching serialized JSON content. History and sequence reads contain **client-serialized JSON, not kernel-normalized content**. Guid casing and date-time spelling can differ from kernel reads (for example `Z` versus `+00:00`, or dropped `.000`); compare Guids case-insensitively and dates as instants, not strings. Objects retain the production serializer's nested content; typed nested schemas are checked, and nested compliance/security metadata still rejects. Formatted strings must be dashed Guids, ISO dates (`YYYY-MM-DD`), or UTC ISO date-times (seconds with no fraction or three-digit milliseconds and `Z`), with valid calendar dates in years 1–9999. Numeric fields must be finite; integer formats enforce their signed/unsigned width and require safe integers (also for int64/uint64). Unknown numeric formats reject. Top-level array fields remain unsupported. Empty schemas are supported only for unique-property removal-only events in the pinned lifecycle fixture. The existing plain-string value domain remains strings containing only printable ASCII (U+0020–U+007E) except `"` and `\`, plus `é` (U+00E9); other strings are rejected with `UnsupportedEventSequenceOperation`. Results include successful appends; accepted history includes setup. |
| Event metadata | Default source/stream routes (`Default`/`All`/`Default`), subject equal to source ID, store `test-event-store`, namespace `default`, correlation, occurrence time (with deterministic scenario clock/ID hooks, UTC years 1–9999 at JavaScript Date millisecond precision), system identity, and a deterministic SHA-256/base64 content hash. Kernel hash parity is established only for the original string/boolean content domain and the new constraint fixture's accepted values; other numeric, Guid, date and object payloads have a **scenario-local** hash, not a persisted-kernel hash. Do not use these hashes to test kernel content canonicalization. Fixture-backed batch routing, tags, subject and occurrence metadata are supported with per-entry precedence; single appends accept only the routing options `sourceType`, `streamType` and `streamId`. Other single-append custom metadata, concurrency and unproven serialization are rejected. The TypeScript client's append causation preparation is shared with production; kernel fixtures verify Root → TypeScriptClient.Append for single appends and Root → TypeScriptClient.AppendMany (with an event-count property) for batches. The .NET client's default one-entry Unknown chain is different. Stored reads expose the kernel's initial observation state (1). |
| Unscoped constraints | Property-level `@unique` and fluent `unique(...).on(Event, event => event.key)` for one case-sensitive, schema-backed **string or boolean** key per event type, plus the isolated Guid and numeric key shapes below, or a fluent composite of two or three string properties, for example `unique(key => key.on(NameRegistered, event => event.first, event => event.last))` (see the composite table below). Fluent `.ignoreCasing()` is supported for string keys in the ASCII key domain (see the case-insensitive table below). The key-domain table below applies to constrained values; it does not widen event content. Class-level `@unique` or fluent `uniqueFor` supports one covered event type per constraint, or one of the two pinned removal-cycle shapes in the table below. Conflicts with accepted history and earlier entries in the same batch reproduce the fixture's violation count and order across events, including default property messages for in-batch conflicts and same-source reclaims, and violation details. A successful same-source replacement frees the old value, and a registered removal event frees that source's value; repeated or wrong-source removals do not free another owner's claim. A covered-and-removal event validates before releasing on commit. During batch validation, neither replacements nor removals release earlier or durable property claims: a batch `[remove A, B claims A's key]` still fails atomically, whereas separate successful appends allow the reclaim. Multiple constraints covering the same event type, a remover covered by another definition, or a remover shared by several definitions are rejected: fixtures do not establish their interaction. Configured `{PropertyName}` and `{PropertyValue}` messages use production substitution. Rejected batches return one failed result per input (sequence number normalized to `0n`), commit nothing, and do not consume sequence numbers; a failed result's `waitForCompletion()` resolves with the SDK's successful no-work value. Setup violations throw and roll back that given call. |
| Empty batches, unresolved removal names, scoped shapes outside the scope table below, composite or case-insensitive keys outside the tables below, overlapping validating definitions, unique-event-type shapes outside the cycle table below, migrations, tombstones, alternate generations, protected fields, array fields, unproven constrained key types, completion/redaction, transactions, observer-tail and unproven metadata/read filters | **Unsupported:** `UnsupportedEventSequenceOperation` names the operation and artifact and says “Use a kernel-backed test.” Unproven key conversions and event content reject before mutation. Explicit `constraints: 'disabled'` is for scenarios that deliberately do not test constraints; it is never a silent fallback. Accepted single appends cannot wait for observer completion because no observers run. |

| Unique-property key domain | In-process support |
| --- | --- |
| Case-sensitive strings | Empty, one-space and padded strings; ASCII letters/digits, space, `.`, `@`, `_`, `-`, `:`, `\|`, `{`, `}`, `$` (including email-like strings); also U+00E9 (`é`). Other punctuation may be valid *event content* but is not proven as a constrained key. No trimming, Unicode normalization or casing conversion. |
| Schema-backed booleans | `true` hashes as `True`, `false` as `False`. A string `"True"` shares a key with boolean `true`; `"true"` is distinct. Violation details contain `True` or `False`, not JavaScript lowercase spelling. |
| Lifecycle | Each source owns its most recently committed key. A replacement frees its old key; a removal event releases that source's claim even without a value. Whole-batch validation still sees pre-batch claims and earlier batch claims until commit, even when removals/replacements occur before another source's reclaim. |
| Guid keys | Dashed GUIDs (`guid` schema format only), including `ConceptAs<Guid>`. The unproven `uuid` constraint schema alias is rejected. Casing is normalized before comparison and in violation details. `ABCDEF01-ABCD-ABCD-ABCD-ABCDEF012345` conflicts with its lowercase spelling. |
| Numeric keys | Unformatted `Number` or `number/double` schemas, including `ConceptAs<number>`, with safe-integer values from `-9007199254740991` through `9007199254740991`. The kernel compares `1` and `1.0` as the same key. Production JavaScript serialization turns `-0` into `0` before the scenario compares it. |
| Guid/numeric definition shape | One unscoped, case-sensitive, single-property definition covering one event type, without removers or other definitions. Same-source replacement and atomic batch conflicts are supported. Evidence: `constraints-field-types.json`, checked against the packaged kernel with literal JSON requests and raw violations. |
| Still rejected | Fractional and unsafe-integer numeric keys, other numeric schema formats, date/date-time, null/missing, array, object or mismatched schema/value keys; composite, scoped, `ignoreCasing`, shared-event and removal definitions for Guid/numeric keys; unproven punctuation, Unicode, escapes and control characters; scoped boolean keys. These restrictions apply to **constraints**, not ordinary numeric/Guid/date/object payloads. Use a kernel-backed test. |

| Composite unique-property key | In-process support (`constraints-composite.json`) |
| --- | --- |
| Shape | Two or three distinct flat string properties per event type, in fluent `on(Event, ...properties)` order. A shared definition may mix a composite on one event type with a composite in a different property order, or a single string key, on another. |
| Key | The kernel joins each property's string with a literal `-` in **declared** order, not field order, then hashes the result. `['Ada', 'Lovelace']` and `['Lovelace', 'Ada']` are different keys. Empty components still participate, so `['', '']` claims `-`. |
| Delimiter collisions | The joined key is not a tuple. `['a-b', 'c']`, `['a', 'b-c']` and a single key `a-b-c` in the same definition collide, as do `['', 'q-r']` and `['-q', 'r']`. Choose component values that cannot contain `-` if this matters. |
| Violations | One violation per declared property, in declared order, each with that property's own `PropertyName` and `PropertyValue` and the same sequence number; a configured message is resolved separately for each. |
| Lifecycle and batches | Same as single keys: same-source reclaim, replacement and removal of the whole composite claim, and whole-batch validation across event types. Separate definitions never collide with each other. |
| Still rejected | Boolean or other non-string components, repeated property paths (the kernel builds a dictionary keyed by path), more than three properties, nested or indexed paths, scoped composite definitions, and any component value outside the case-sensitive string domain above. Use a kernel-backed test. |

| Case-insensitive unique-property key | In-process support (`constraints-ignore-casing.json`) |
| --- | --- |
| Folding | Fluent `unique(key => key.on(HandleRegistered, event => event.handle).ignoreCasing())`. The kernel lowercases the joined key with .NET `ToLowerInvariant` before hashing; for the ASCII key domain that maps only `A`–`Z` to `a`–`z`. `Alice`, `ALICE` and `alice` collide; digits, spaces and the punctuation listed above are unchanged and still significant (`Alice` and `Alice ` differ). |
| Details and messages | Violation details keep the attempted value's **original** casing (`ALICE`, not `alice`), in configured messages too. |
| Ownership | A source may reclaim its own key with different casing; the violation then reports the latest claim's sequence. Replacement and removal release the folded key. Batches use the same folded key, so `[F: Zed, G: zED]` fails atomically. |
| Composites | Folding applies across the whole joined key, so `['Ab-C', 'd']` and `['ab', 'c-D']` collide; `['AB', 'C-E']` does not. |
| Still rejected | Any non-ASCII character (including `é`, which case-sensitive keys accept), because the pinned fixtures do not establish the kernel's Unicode lowercasing and host JavaScript case tables are not a substitute; boolean keys or scoped definitions under `ignoreCasing`; `@unique` merged with a case-insensitive fluent definition (a compiler conflict). Use a kernel-backed test. |

`constraints-field-types.json` also records `oracleGuard` cases, not extra supported domains. Fractional/exponential and unsafe-integer requests capture the kernel's formatting without promising general JavaScript/.NET floating-point equivalence. The date-time case uses the installed `string/date-time` schema (`DateTime`, matching TypeScript `Date`), not `DateTimeOffset`. Equivalent instants with different offsets collide, and fractional seconds disappear from keys and violation details. Date keys therefore remain rejected rather than using JavaScript date equality. Each case records and asserts the effective installed property schema; numeric evidence covers both the exact unformatted TypeScript `number` schema and `number/double`.

Rejecting non-ASCII keys under `ignoreCasing` is a deliberate, permanent default, not a pending gap: the scenario maintains no Unicode case mapping of its own, so it cannot drift from the kernel's. Test non-ASCII case-insensitive keys against a kernel.

| Unique event type cycle | In-process support |
| --- | --- |
| Definition shapes | Exactly the two pinned shapes, each as the **only** definition in the scenario: two covered types with two separate removers (`constraints-event-type-siblings.json`), or three covered types with three removers where one covered type is also a remover (`constraints-event-type-cycles.json`). Same-named class-level `@unique` or fluent `uniqueFor` declarations compile to one definition; removers use `@removeConstraint('Name')` and must have at least one field. |
| Claims | Per event source, any covered event blocks every covered type until a remover for that source commits. Another source's remover releases nothing. Either remover releases; a remover without an open cycle is accepted. Violations use the definition's message. |
| Covered remover | Validated against the open cycle first: blocked while the cycle is open, otherwise it claims and immediately releases, so a later covered event in the same batch succeeds. A blocked covered remover releases nothing. |
| Atomic batches | A remover earlier in the batch releases the cycle for later events in the batch; a second covered event after an in-batch claim is blocked. A rejected batch commits nothing, including its releases. The raw kernel violation reports the durable holder's sequence number (the first covered event after the source's latest committed remover), or `18446744073709551615` when only an earlier event in the batch holds the cycle. |
| Still rejected | One covered type with removers, several covered types without removers, any other count or overlap of covered and removal types, a cycle definition next to any other definition, fieldless event-type removers; scoped covered-and-removal cycles. Use a kernel-backed test. |

The committed `Source/testing/fixtures/*.json` snapshots run through the real in-process kernel via the pinned `Cratis.Chronicle.Testing` 19.26.2 oracle. `yarn oracle:check` verifies them alongside projection fixtures. The fixture tests also compare the TypeScript client’s serialized content, context fields, hash, result shape and essential reads. The boundary fixture covers an empty string, the supported printable ASCII range, a mixed-case property name and exclusion of a different event type on the same source. The source-tail fixture distinguishes the last event for A from the global tail. `batches.json` checks kernel-stored resolved per-entry metadata, the tag merge (including duplicate removal), correlation ID, ordering, hashes, batch causation, read filters and empty-batch rejection. Its .NET client-path oracle resolves per-entry versus shared route, subject and occurrence options **before** sending each event and supplies explicit route defaults; it does not independently prove those precedence rules. Those rules come from production `prepareBatchAppend` shared with the scenario. The separate `batch-omitted-routes.json` fixture bypasses the .NET convenience type and sends genuinely omitted and empty routes through the pinned kernel's batch service, verifying `Default`/`All`/`Default` resolution without claiming client-path notifications. Empty subjects remain outside the supported domain; the scoped wire fixtures also prove empty-route omission through the TypeScript encoder. `builders.json` proves .NET's sequential setup/action semantics; `batch-rollback.json` proves rejected unique-constraint batches are atomic and leave no sequence gap. `constraints.json` proves string key ownership across event types and sources, ordinal casing and significant spaces, decorated and fluent unique-event-type rejection, raw wire violation fields, default property messages for in-batch conflicts and post-reclaim cross-source conflicts, successful same-source same-key batches with both sequences committed and a subsequent cross-source conflict reporting the last sequence, multiple failures on different events in one batch, and atomic rollback. It does not prove the violation order when one event violates multiple definitions; scenarios reject that overlap. These cases exercise the packaged kernel; the TypeScript spec compares every result and committed-history snapshot to the fixture. `constraints-isolation.json` selects only its declared text definition, and `constraints-key-domain.json` installs a shared string/boolean definition with explicit schemas. Both capture raw kernel violations, mapped results, routes, content, hashes and accepted history after each single or batch operation; failed appends assert unchanged history and sequence. The oracle checks effective installed definitions against fixture order, property names, scope, removal and casing and pins both the sequence and constraint contract descriptors. `constraints-property-lifecycle.json` and `constraints-property-covered-removal.json` separately capture replacement, removal, and batch non-release from pinned kernel storage: raw and mapped violations, every stored hash and history snapshot, and atomic rollback. The removal fixture checks three alternative removers, including a fieldless event; the covered-removal fixture shows validation before release. `constraints-event-type-siblings.json` and `constraints-event-type-cycles.json` each install one unique-event-type definition and capture cycle claims, releases by each remover, other-source removers, repeated removers, a covered remover, in-batch release and reclaim, blocked batches and their rollback, with raw and mapped violations and every stored hash. Composite keys are not inferred from these fixtures; `constraints-composite.json` separately installs a two-property definition shared across three event types (including a reversed declared order and a single key) and a three-property definition, and captures declared-order joining, delimiter collisions, empty components, per-property violations and messages, reclaim, replacement, removal and batch collisions. `constraints-ignore-casing.json` installs a case-insensitive single key with a remover and a case-insensitive composite, and captures ASCII case pairs, punctuation/digit/space significance, case-only same-source reclaim, original-cased details, replacement, removal, batch collisions and folding across component boundaries. Blank or whitespace-padded source filters are rejected until their normalization is fixture-backed. They do not establish production storage, concurrency, compliance or scheduler fidelity; use a kernel-backed test for those behaviors.

Named tags are not fixture-backed. A nonempty `namedTags` option or batch entry rejects with `UnsupportedEventSequenceOperation` (`append.namedTags` or `appendMany.namedTags`); an empty list is accepted, and stored contexts carry an empty `namedTags` list as a kernel read would. `ReadModelScenario` setup rejects them the same way. Use a kernel-backed test for named tags.

## Scoped constraints and append routing

Use a fluent constraint to keep unique values independent per stream ID. `@unique` itself is unscoped; do not combine it with a same-named scoped fluent definition.

```typescript
import { field } from '@cratis/fundamentals';
import { constraint, eventType, IConstraintBuilder } from '@cratis/chronicle';
import { EventScenario } from '@cratis/chronicle/testing';

@eventType('ScopedEmailRegistered')
class ScopedEmailRegistered {
    @field(String) email: string;
    constructor(email: string) { this.email = email; }
}

@constraint('EmailPerStream')
class EmailPerStream {
    define(builder: IConstraintBuilder) {
        builder.perEventStreamId().unique(key => key.on(ScopedEmailRegistered, event => event.email));
    }
}

const scoped = new EventScenario({
    artifacts: { eventTypes: [ScopedEmailRegistered], constraints: [EmailPerStream] }
});
await scoped.given.forEventSource('first', { streamId: 'west' }).events(new ScopedEmailRegistered('alice'));
const independent = await scoped.when.forEventSource('second', { streamId: 'east' }).event(new ScopedEmailRegistered('alice'));
const duplicate = await scoped.when.forEventSource('third', { streamId: 'west' }).event(new ScopedEmailRegistered('alice'));
// independent.isSuccess === true; duplicate.isSuccess === false.
```

For a combined scope, replace `builder.perEventStreamId()` with `builder.perEventSourceType().perEventStreamType().perEventStreamId()` and pass, for example, `{ sourceType: 'Customer', streamType: 'Registration', streamId: 'west' }` to `forEventSource(id, options)` or `append`. Every selected dimension must match to share a constraint scope. Event **source ID** is already the property-claim owner or event-cycle identity; source **type** is a separate, optional scope dimension.

| Scoped constraint | In-process support (`constraints-scopes.json`) |
| --- | --- |
| Dimensions | All seven nonempty combinations of `perEventSourceType()`, `perEventStreamType()` and `perEventStreamId()`, using exact, case-sensitive comparisons. Changing an unselected dimension does not open a new scope. |
| Definitions | One definition per scenario: case-sensitive single-string property keys (including shared event types and ordinary removal events), one unique event type without removers, or two covered event types with two separate removers. Use fluent `uniqueFor(Event, message, sharedName)` on separately identified `@constraint` classes to merge scoped covered types; `@removeConstraint(sharedName)` names their removers. |
| Ownership and cycles | The same source can hold a key in each scope. Replacement and removal affect only that owner's matching scope. Property claims remain held throughout batch validation; event-type cycles can release and reopen within a batch. A failed batch releases nothing. |
| Routes and defaults | `append(source, event, options)`, both `appendMany` overloads, and `EventScenario`/`ReactorScenario` given/when builders support routing. Omitted or empty dimensions resolve to `Default` / `All` / `Default`. These are **exact scope values**, unlike the non-narrowing defaults in read filters. Per-entry batch routes override shared options, even when the per-entry string is empty. |
| Still rejected | Scoped composites, `ignoreCasing`, boolean keys, fieldless removers, covered-and-removal events, and scoped definitions alongside other definitions. Route identifiers containing spaces, delimiters, non-ASCII or other unproven characters remain rejected. Custom stores/sequences, concurrency and arbitrary single-append metadata still need a kernel-backed test. |

The oracle asserts effective installed scopes in the packaged kernel and executes both the .NET client path and decoded TypeScript protobuf requests. It compares raw violations, mapped results, full history, routes, hashes and rollback. Its delimiter-alias guards show why property scope strings and event-cycle tuples are not interchangeable; the scenario rejects those identifiers rather than promising storage-provider alias behavior.

Select a route with `given.forEventSource(id, options?: AppendOptions)` or `when.forEventSource(id, options?: AppendOptions)`. The options apply to every event in that builder call. Setup uses the same validation and scoped constraints as `append`; `EventScenario.when.event` uses single append, while `when.events` uses atomic `appendMany`. Unsupported options throw exactly as the corresponding direct append does: setup and single actions accept only `sourceType`, `streamType` and `streamId`; batch actions retain the batch metadata boundary described above. Omitting options preserves default routing.

`ReactorScenario` passes the recorded route to handlers in `EventContext`, and retains it in handled/skipped contexts and each side effect's `triggeringContext`. Reactor handlers can also append non-subscribed events through `services.eventStore.eventLog` with the same supported options; those service appends are recorded but not delivered.

`ReadModelScenario.given.forEventSource(id, options?: AppendOptions)` shares single-append route validation and accepts omitted, explicit or empty-string defaults. Nondefault routes still throw `UnsupportedProjectionOperation` before collecting events: routed projection/reducer evaluation is not fixture-backed. The overload does not enable projection routing.

## ReactorScenario: live event deliveries and recorded effects

`ReactorScenario` shares the production reactor's handler discovery, per-event invocation boundary and returned-event normalization. Its input is the fixture-bounded `EventScenario`: each `given` or `when` call appends registered events and immediately delivers their serialized history in source-partition order. Reactor inputs are **client-serialized JSON, not kernel-normalized content**, so Guid casing and date-time spelling can differ from production (for example `Z` versus `+00:00`, or dropped `.000`). Compare Guids case-insensitively and dates as instants, not strings. `given.events` uses sequential single appends; `when.events` uses an atomic batch (including one event). Both await completion before returning. A returned event is **recorded, not appended or recursively delivered**. In production, returning an event type that the same reactor subscribes to delivers it back to the reactor; the scenario does not simulate that feedback loop.

```typescript
import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle';
import { reactor } from '@cratis/chronicle/reactors';
import { ReactorScenario } from '@cratis/chronicle/testing';

class WelcomeRequested {
    @field(String) name: string;
    constructor(name: string) { this.name = name; }
}
eventType('WelcomeRequested')(WelcomeRequested);

class WelcomeSent {
    @field(String) name: string;
    constructor(name: string) { this.name = name; }
}
eventType('WelcomeSent')(WelcomeSent);

class WelcomeReactor {
    welcomeRequested(event: WelcomeRequested) { return new WelcomeSent(event.name); }
}
reactor('WelcomeReactor')(WelcomeReactor);

const reactorScenario = new ReactorScenario(WelcomeReactor, {
    artifacts: { eventTypes: [WelcomeRequested, WelcomeSent] },
    constraints: 'disabled'
});
await reactorScenario.given.forEventSource('customer-1').events(new WelcomeRequested('alice'));
await reactorScenario.when.forEventSource('customer-2').events(new WelcomeRequested('bob'));
reactorScenario.shouldHaveProduced(WelcomeSent, sent => sent.name === 'bob');
// reactorScenario.produced contains two events; returned events are not delivered again.
```

`results` contains a delivery outcome per nonempty call: the handled and skipped event contexts, completion status, and any error. `sideEffects` retains each returned event, its target routing, triggering context, handler and delivery index; `produced` flattens the event values. `then` is a non-callable view of all three. A failed delivery records its partial observations and **rejects the awaited call**. It stops on the failing event; the undelivered tail is not included in the outcome. After a delivery failure the whole scenario rejects further deliveries (including to other partitions) before appending, since failed-partition retries and checkpoints are not simulated. Use a kernel-backed test to exercise recovery.

With `artifactActivator`, the scenario activates once per source-partition delivery with the first invocable event context, calls `run` for each handled event **and its returned effects** with `delivery: Events`, that event's context and its method name, calls `complete()` once even after processing failure, then disposes. Both processing and completion failures survive in `ArtifactCompletionFailed`; disposal-only failures are logged. Constructor dependencies belong in this production activator, not a scenario-specific DI container. Without an activator the instance is reused across deliveries, matching the TypeScript runtime (not .NET's fresh-instance default). The third handler argument is production `ReactorServices`; the default scenario store exposes its event log for explicit appends, but unsupported read-model and other store methods reject. Explicit appends are recorded in history but **not delivered to any observer**. An append (including a mixed batch) of an event type this reactor subscribes to rejects before committing, rather than silently skipping its follow-up delivery. An explicit store test double is outside that guard; its behavior is the test's responsibility. Supply `servicesEventStore` as an **explicit test double** for other dependencies. A `resultHandler` follows production semantics: returning true claims the whole result, false falls back to recording, and throwing fails delivery.

The existing committed EventScenario kernel fixtures prove zero-based sequence allocation, serialized context fields and initial observation state for their own event histories: `builders.json` covers sequential setup appends and `batches.json` covers atomic batch actions. Reactor specs exercise contexts from the same EventScenario boundary, not a field-by-field comparison with the fixtures. Grouping one multi-event `given.events` call into one delivery is a scenario convention, not a claim about kernel queue batching. Runtime reactor activation and failure specs exercise the shared dispatcher. This does **not** simulate observer scheduling, retries, checkpoints or distributed ordering. Constraint-bearing reactors use the same selected EventScenario catalog: a rejected append does not deliver to a handler. A rejected `when.events` action throws `ReactorScenario action append failed` with the failed append results (constraint names, resolved messages and errors); a rejected `given.events` setup throws `EventScenario given setup failed` with its failed result. `@filterEventsByTag` reactors reject at construction; `replay()` and `redeliver()` reject with `UnsupportedReactorOperation` until a separate kernel-backed increment. An unsupported service or event-log call inside a handler (including `waitForCompletion()` on an append result) fails the delivery at that event with its `UnsupportedReactorOperation` or `UnsupportedEventSequenceOperation`, even if the handler catches the rejection: nothing is recorded for that event and later events are not delivered. A call is attributed to the delivery whose handler started it, and only while that delivery is running; leftover work from an earlier delivery never fails a later one. `commandTypes` and unknown return shapes likewise reject (commands are not classified in this increment). Returned effects are not validated as kernel appends, except that `namedTags` on a returned `EventForEventSourceId` are validated as production dispatch does: an invalid tag fails the delivery with `InvalidNamedTag`, and valid tags are kept on the recorded `target`. A returned effect or batch that combines non-empty named tags with any registered `eventSource` entry fails with `NamedTagsWithRegisteredEventSourceNotSupported` before any effect is recorded, even when the tags and registered source belong to different entries. Reactor effects are always appended as a batch, so this also applies to a single returned effect. Empty named-tag arrays remain accepted. This matches the production guard for the [kernel's registered-source routing limitation](https://github.com/Cratis/Chronicle/issues/4603). Use a kernel-backed test for storage, replay, read-model materialization or command execution.

## ReadModelScenario

Import `ReadModelScenario` from `@cratis/chronicle/testing`. Associate a reducer with its read model using the third argument of `reducer()`. Seed events for an event source, then await the instance:

```typescript
import { eventType, reducer } from '@cratis/chronicle';
import { ReadModelScenario } from '@cratis/chronicle/testing';

class BookBorrowed {
    constructor(readonly title: string) {}
}
eventType('book-borrowed')(BookBorrowed);

class BookStatus {
    title = '';
}
class BookStatusReducer {
    bookBorrowed(event: BookBorrowed): BookStatus {
        return { title: event.title };
    }
}
reducer('book-status-reducer', undefined, BookStatus)(BookStatusReducer);

const scenario = new ReadModelScenario(BookStatus);
scenario.given.forEventSource('book-42').events(new BookBorrowed('Dune'));
const status = await scenario.instanceForEventSourceId('book-42'); // { title: 'Dune' }
```

`scenario.instance` returns the sole materialized model (and rejects ambiguity if more than one exists). For multiple sources use `await scenario.instanceForEventSourceId(id)`; both return `null` when no model exists. `await scenario.wasDeletedForEventSourceId(id)` distinguishes a removed model from one never created. Events replay in seed order, with zero-based sequence numbers assigned globally across sources. Adding seeds after a read replays the complete history. When a reducer and a projection both apply, the reducer takes precedence: its handlers receive previous state and `EventContext`, async handlers are awaited, `undefined` deletes the model, and `null` preserves a null state for the next handler.

## Projection capabilities

Without a reducer, `ReadModelScenario` compiles the same model-bound or declarative projection contract used by registration and validates the **whole definition before replay**. Supply all participating event types in the optional artifact catalog if you isolate discovery. For a declarative projection without an explicit read-model type, also supply the production read-model catalog (`readModels`) so association is inferred against the same candidates; without it the scenario does not link that projection. An unsupported mapping fails even if you never seed its event. A read model cannot have multiple applicable projections.

| Capability | In-process scenario |
| --- | --- |
| Root `fromEvent` / `.from()` keyed by `$eventSourceId`, multiple event types and source IDs | Supported for read models with a lowercase `id` schema: string, canonical GUID, or canonical `number/double` identifiers. Missing, `Id`-only, unformatted number, and integer identifier schemas require a kernel-backed test. |
| `setFrom`, schema-based case-insensitive AutoMap, `noAutoMap` | Supported for **scalar targets only** (string, nullable string, boolean, GUID, date-time, number/double, int32/uint32), with matching source type/format, GUID to plain string, int32/uint32 to number/double (including unformatted TypeScript `Number`), and numeric text to int32/uint32/number/double (including unformatted `Number`). Decimal-spelled text is rejected for integer destinations. Date-time mappings accept validated four-digit-year UTC ISO values (no fraction or three-digit milliseconds) in the .NET DateTime range (years 1–9999); other formats and out-of-range values are rejected. Explicit mappings from event properties that differ only by case preserve each non-null exact-case value; a null exact-case value falls back to the first case-insensitive property in JSON order, which can itself be null. Ambiguous inferred AutoMap sources are rejected. The event-property paths `true`, `True`, `false`, and `False` are rejected because the kernel resolves them as literals. Dotted event-content source paths are not fixture-backed and are rejected. Event string/GUID fields must contain JSON strings. Other cross-type conversions and object/array target mappings require a kernel-backed test. Mapping `id` (including AutoMap), or a target that collides case-insensitively with another schema property, is rejected. |
| `$eventSourceId`, proven `$eventContext(...)` scalar roots/paths, `$value(...)`, `$null` / scalar clearing | `$eventSourceId` requires a plain string or GUID target; other target types require a kernel-backed test. Fixture-backed context paths have string targets for EventSourceId, EventStore, Namespace, EventSourceType, EventStreamType, EventStreamId, Subject (and `.Value`), Hash, CorrelationId (and `.Value`), CausedBy.Subject/Name/UserName, EventType.Id.Value, and SequenceNumber (and `.Value`); int32 targets for EventType.Generation.Value and Occurred.Year/Month/Day. Subject defaults to the event source ID; omitted Hash is empty and CausedBy uses the kernel's `[Not Set]` identity. Raw Occurred, whole EventType/CausedBy, Causation, Tags, ObservationState, and derived functions require a kernel-backed test. Literal values are supported for the fixture-backed scalar matrix (number/double, int32, uint32, boolean, string, nullable string, and GUID); `$null` clears populated scalar members, including non-nullable number, boolean, GUID, and string. Date-time literals require a kernel-backed test. |
| Root arithmetic: fluent `.add(...).with(...)`, `.subtract(...).with(...)`, `.count(...)`, `.increment(...)`, `.decrement(...)`; model-bound `@addFrom`, `@subtractFrom`, `@count`, `@increment`, `@decrement` | Supported in both decorator modes with unformatted `number` accumulators (`@field(Number)`), keyed by `$eventSourceId`. The evaluator also accepts explicitly registered number/double and integer/int32 schemas; decorators do not generate these formats, so int32 wraparound applies only to hand-built or foreign schemas. Add/subtract operands must be direct event properties with number/double, int32 or uint32 schemas. The pinned kernel permits an underscore only as the first character of an operand name: snake_case names such as `unit_price` are rejected before replay ([kernel limitation #4491](https://github.com/Cratis/Chronicle/issues/4491)). Missing accumulators start at zero; missing/null numeric event fields contribute zero. Zero arithmetic leaves an absent accumulator absent, while public reads return the schema's zero default. Double arithmetic retains binary floating-point precision, including rounding at large magnitudes. Int32 operands round ties to even, reject out-of-range conversions, then use unchecked wrapping arithmetic. Scalar initial values, independent sources and removal/recreation are supported. An aggregate-only event suppresses AutoMap; mixing arithmetic with an explicit scalar assignment restores normal AutoMap. |
| Arithmetic outside that shape | Rejected before replay: uint32 or other accumulator formats, nullable/non-numeric operands or targets (including numeric strings), nested/context/literal operands, clearing an accumulator (including a text assignment that could clear it), null initial numeric values, custom/constant keys, and arithmetic combined with children or joins. Arithmetic inside children or `fromEvery` remains unsupported. Invalid seeded numeric values and out-of-range int32 operand conversions fail replay rather than silently coercing. Use a kernel-backed test. |
| Initial projection values; empty mappings; `removedWith`; removal/recreation | Fixture-backed scalar initial values (matching JSON kind, finite numeric range, canonical lowercase GUIDs, or null for nullable strings); unmapped array and object schema members are supported with either empty or scalar non-empty initial state (empty state seeds an empty array; non-empty state omits the array; both omit the object). Initial object, array and date-time values require a kernel-backed test. Each key gets its own initial state, reapplied on recreation; unrelated events do not resurrect a removed model. An event subscribed through both `From` and `RemovedWith` requires a kernel-backed test. |
| Children: `@childrenFrom` (including the `{ childType, key, identifiedBy, parentKey }` options form) and fluent `.children(...)` with `.identifiedBy(...)`, `.from(...).usingKey(...)` and `.removedWith(...).usingKey(...)` | One level of children in an array-of-objects property, keyed by a **string event property** into a direct identifying property, under the event source's parent (no parent key, or `$eventSourceId`). A child event adds the child when its key is new and otherwise updates that child in place; a child removal removes the matching child and ignores an unknown key. An event handled by both the root and a child collection applies both. The first event that only a child collection handles creates the parent **uninitialized** (just `id` and the collection); the parent's initial values are filled in by the next event for that key, and a child removal for an unknown parent creates such a parent with an empty collection. Child AutoMap is schema-driven: in both decorator modes, a child type supplied through `@childrenFrom` or `@field` with resolvable runtime members produces a typed item schema that maps same-named scalar event properties. With legacy decorators, an unknown child type, a child with no resolved properties, or a child whose resolved properties omit its identifier produces an untyped item schema (`{ "type": "object" }`) and stores only the identifier. Pass `childType` and make its members discoverable with initializers or `@field` to map the child's properties; partially resolved children stay typed if their identifier resolves. AutoMap into the child identifier is supported only when its sole source is that entry's child key. Typed legacy children now intentionally reject non-string identifiers (for example, `id = 0`) and incompatible AutoMap types that an untyped item schema previously left unchecked. Explicit child property mappings other than the identifier are not supported. Fixtures: `children-from-keyed`, `children-identified-removed`, `children-typed-items`, `children-untyped-items`, `children-identifier-equals-key`. |
| Children outside that shape: child keys from `$eventSourceId`, context, constants, composites or non-string properties; parent keys from event properties; a child identifier that is missing, nested, not a plain string, mapped from anything but the child key, or AutoMapped from a different or ambiguous source; disabled child AutoMap or child AutoMap exclusions; nested children or nested projections inside children; explicit child property mappings (renames, event context, constants, nulls, arithmetic); children combined with initial model state or root arithmetic; child joins, `removedWithJoin`, `fromEvery`/all and `FromEventProperty` value children; one event in several children operations; an event that both removes and changes the parent or a child | Rejected before replay with `UnsupportedProjectionOperation` naming the `Children.<property>` contract path: use a kernel-backed test. |
| Root joins: fluent `.join(Event, join => join.on(...).set(...).to(...))` and model-bound `@join(Event, 'foreignKey', 'eventProperty')` | One join event type, keyed by its `$eventSourceId`, matching a direct non-`id` read-model property populated by a root `From` mapping (explicit or AutoMap). Read-model properties and mapped event properties must be plain strings. Supports latest-prior-event backfill, later updates to every matching root, foreign-key changes, and root removal/recreation without deleting joined history. Join events never create a row by themselves. AutoMap respects explicit destinations, already-consumed source properties and `noAutoMap` exclusions. |
| Joins outside that shape | Rejected before replay: multiple joins, join-only projections, joins without effective mappings, initial values, non-string/nullable/formatted schemas, context/constant/nested expressions, custom join keys, identifier joins, a join mapping its own join target, overlapping root/join property mappings or event types, `removedWithJoin`, joins inside children, and joins combined with children or arithmetic. Use a kernel-backed test. |
| `.fromAll(...)` / `@fromAllEvents(...)` and `.fromEvery(...)` / `@fromEvery(...)` / `@fromAll(...)` | Rejected before replay with `UnsupportedProjectionOperation`: use a kernel-backed test. `.fromAll(...)` and `@fromAllEvents(...)` subscribe to every event type, including undeclared types; `.fromEvery(...)`, `@fromEvery(...)`, and the deprecated `@fromAll(...)` alias only map events already subscribed to. Contrary to the earlier 'next major' announcement, `@fromAll` behavior will not change. [Opting into `@fromAllEvents`](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/client-snippets/projections/model-bound/from-all/attribute-convention.md#opting-into-all-event-subscriptions), not upgrading alone, changes the definition and may trigger replay. |
| Nested projections, variants, derivatives, custom/composite/constant root keys, dynamic destinations, passive projections, non-default event sequences, root `FromEventProperty` | Not supported: use a kernel-backed test. |
| Unsupported numeric formats; incompatible mapping types; multi-generation event-type history; derived context functions; migrations, compliance, scheduling, storage | Not simulated: use a kernel-backed test. Compliance/security metadata anywhere in the read-model schema (including id, defaulted, nested, and unmapped members) is rejected before replay. |

A root `From` event materializes an identifier-only model even if it changes no mapped properties. Missing content resolves to null but does not add an absent null-valued member; `$null` clears a previously populated member. Public reads omit null fields and apply the kernel's schema defaults (zero, false, empty GUID, and minimum date-time) to missing non-nullable scalar fields; supported non-empty scalar initial state is schema-converted before mappings run; object-shaped initial values are rejected until fixture-backed. The in-memory sink's typed key populates lowercase `id`; phase 1 rejects mappings that write that sink-managed property. A seeded event with the same ID but a different generation from the subscribed event is rejected before replay. Identifiers whose text would canonicalize to a different key (for example `01` as a numeric key or mixed-case GUID text) are rejected to avoid merging distinct sources.

Arithmetic fixtures cover all five operations, zero and absent values, null numeric inputs, initial values, fractional and large doubles, int32 rounding and wraparound, operand-conversion failures, AutoMap suppression and removal/recreation. They do not prove arithmetic combined with joins or storage-provider behavior.

The `joins-*` fixtures separately capture string-only root joins. Matching is case-sensitive. A later join event with a missing/null string leaves existing joined properties unchanged; backfilling that same event from a root `From` clause can clear a populated property. Backfill runs only for clauses that map the foreign key, not unrelated root updates. Removing a root does not remove source-event history, so recreation resolves the latest prior join event. These fixtures do not cover persistent storage, join scheduling or durable futures.

The evaluator is checked against committed, per-step fixtures from the pinned production Chronicle projection pipeline (`Source/testing/projections/fixtures/`); `kind: oracleGuard` describes kernel behavior outside the narrowed phase-1 surface (or oracle safety boundaries): the evaluator must reject these fixtures rather than reproduce their kernel snapshots. Failing oracle fixtures also assert the pinned kernel's expected error. Run `yarn oracle:check` to detect drift and use the oracle's update mode only when reviewing changes to the pinned kernel. A green in-process scenario is **not** evidence for persisted reads, observer scheduling, event migrations, compliance encryption, storage conversion, or advanced projection relationships. For broader projections, use ChronicleKernelScenario or a live-kernel test rather than the in-process evaluator.

## Composing scenarios: read models over a shared event sequence

`ReadModelScenario.observe(source)` makes a read-model scenario read the committed history of an `EventScenario` or a `ReactorScenario` instead of its own seeded events. Nothing is copied: every read (`instance`, `instanceForEventSourceId`, `wasDeletedForEventSourceId`) replays the source's accepted history as it is at that moment, through the same reducer or validated projection that seeded events use. Append with the event scenario, then read the resulting models:

```typescript
import { field } from '@cratis/fundamentals';
import { eventType, fromEvent } from '@cratis/chronicle';
import { EventScenario, ReadModelScenario } from '@cratis/chronicle/testing';

@eventType()
class TestingCompositionAuthorRegistered {
    @field(String) name: string;
    constructor(name: string) { this.name = name; }
}

@fromEvent(TestingCompositionAuthorRegistered)
class TestingCompositionAuthor {
    @field(String) id = '';
    @field(String) name = '';
}

const compositionEvents = new EventScenario({ artifacts: { eventTypes: [TestingCompositionAuthorRegistered] } });
const compositionAuthors = new ReadModelScenario(TestingCompositionAuthor).observe(compositionEvents);

await compositionEvents.given.forEventSource('author-1').events(new TestingCompositionAuthorRegistered('Ursula'));
await compositionEvents.when.forEventSource('author-2').events(new TestingCompositionAuthorRegistered('Octavia'));

const compositionAuthor = await compositionAuthors.instanceForEventSourceId('author-2');
if (compositionAuthor?.name !== 'Octavia') throw new Error('Expected the appended author');
```

`ReactorScenario.appendedEvents` exposes the reactor's shared history: its `given`/`when` input events and any events a handler appends explicitly through `services.eventStore.eventLog`. A value a handler **returns** is recorded in `produced`/`sideEffects`, not appended, so it never reaches an observing read model; this matches the reactor section above. Observing is one-way and explicit. No observer is scheduled, and the read model does not feed back into the reactor.

A scenario reads either its own seeded events or one observed sequence: calling `given...events` on an observing scenario, observing after seeding, or observing twice rejects with `UnsupportedProjectionOperation`. An observed event with a non-default event source type or stream (from routed `append` or `appendMany` calls) also rejects on read, because no fixture establishes how projections treat routed events. Rejected appends are not in the accepted history, so they never reach the read model. Observed `$eventContext(Hash)` mappings reject events whose schemas contain numeric, Guid, date or object fields: those payloads can have scenario-local hashes rather than kernel-normalized hashes. Observing reducers receive **client-serialized JSON, not kernel-normalized content**, just like event history and sequence reads. Guid casing and date-time spelling can differ from production (for example `Z` versus `+00:00`, or dropped `.000`); compare Guids case-insensitively and dates as instants, not strings. Ordinary scalar mappings still apply the supported projection conversions to those serialized fields. Every other rule in [Projection capabilities](#projection-capabilities) applies unchanged. Command recording and read-model snapshots taken from reactor services are not part of this composition; use a kernel-backed test for those.
