Where the clients differ
One kernel contract does not make the client APIs interchangeable. This reference covers appending and concurrency, compliance, reactor delivery, and event seeding. It does not claim parity for other features.
The comparisons below describe the client source inspected on September 23, 2026. The TypeScript rows were checked again on September 30, 2026 against Chronicle.TypeScript v6.31.2, and the Elixir rows on October 15, 2026 against Chronicle.Elixir v3.13.0. Java uses Java-callable interfaces, blocking facades, and bridges that delegate to the Kotlin implementation. Source links identify the implementation behind each comparison. These are source-level comparisons, not cross-client runtime conformance results.
Appending and concurrency
Section titled “Appending and concurrency”Default concurrency checks
Section titled “Default concurrency checks”| Client | Append without an explicit concurrency scope |
|---|---|
| .NET | The default OptimisticConcurrencyStrategy reads the event source’s tail within the selected stream scope and sends an expected sequence number. ConcurrencyOptions.DefaultStrategy can change this behavior. |
| Kotlin and Java | Use ConcurrencyScope.none; no optimistic concurrency check is requested. |
| TypeScript | Sends an unset expected sequence number; no optimistic concurrency check is requested. |
| Elixir | Sends no concurrency scope by default. A client configured with concurrency: [default: :optimistic] reads the tail, narrowed the way the append is routed, and expects it; check_first_append: true also checks the first append into an empty scope. |
The .NET default detects an intervening append between its tail read and write. It does not protect an earlier application read automatically. When no event matches the scope, the default leaves the first append unchecked; CheckFirstAppendIntoAScope enables that check and defaults to false.
Choose an explicit scope when correctness depends on the state you previously read. Do not infer the application’s concurrency guarantee from a successful plain append.
Sources: .NET strategy, .NET defaults, JVM append, TypeScript append, Elixir append, Elixir default strategy.
Expecting no matching event
Section titled “Expecting no matching event”.NET exposes ConcurrencyScopeBuilder.ExpectingNoMatchingEvent; the JVM builder exposes withExpectsNoMatchingEvent(), which its Java bridge documents as directly callable from Java. Both send the kernel’s explicit no-matching-event condition. The TypeScript client sends it too, when the scope’s sequence number is EventSequenceNumber.beforeFirst.
The Elixir client sends it too: ConcurrencyScope.expecting_no_matching_event/1 sets expects_no_matching_event, and the append converter passes it to the kernel as ExpectsNoMatchingEvent. An unset expected sequence number is not a substitute: it disables the sequence-number check rather than asserting that no event exists.
Sources: .NET conversion, JVM builder, kernel validation, Elixir scope, and the TypeScript and Elixir append implementations linked above.
Results and rejections
Section titled “Results and rejections”| Client | Result shape |
|---|---|
| .NET | AppendResult exposes the appended position and rejection details. AppendManyResult carries the batch’s sequence numbers and violations. |
| Kotlin and Java | AppendResult per event; a batch returns a list. Java can read the position through getSequenceNumberValue(). |
| TypeScript | AppendResult per event; a batch returns an array. |
| Elixir | :ok or an error tuple for append/3 and append_many/3. append_with_result/3 and append_many_with_result/3 return an AppendResult carrying the assigned sequence numbers, and constraint and concurrency violations; a rejection comes back as {:error, %AppendResult{}}. |
Inspect the result for kernel constraint and concurrency rejections. This does not mean append APIs never throw: unknown event types, connection failures, and other client or transport failures can still throw or reject. Kotlin also throws ChronicleCommandRejected for command-level authorization and exception responses.
Sources: .NET results, JVM result, TypeScript result, Elixir result, Elixir result conversion.
Compliance and PII
Section titled “Compliance and PII”Resolving the subject on append
Section titled “Resolving the subject on append”| Client | When no explicit append subject is supplied |
|---|---|
| .NET | Resolves an event’s [Subject] property before appending. The kernel falls back to the event source id when no subject is set. |
| Kotlin and Java | Default to the event source id. The append path does not resolve event-side @Subject metadata. |
| TypeScript | Uses options.subject ?? eventSourceId; the append path does not resolve an event’s @subject property. |
| Elixir | An explicit :subject option wins. Otherwise the field named by the event type’s subject: declaration supplies it; with neither, the client sends an empty subject and the kernel falls back to the event source id. |
Read-model subject metadata and append subject resolution are separate capabilities. When the person whose PII you store differs from the event source, pass the subject explicitly rather than assuming an annotation selects it in every client. Choosing the wrong subject changes whose erasure key protects the data.
Sources: .NET subject resolver, kernel append, and each client’s append implementation linked above.
Authorizing a new key after erasure
Section titled “Authorizing a new key after erasure”All five language surfaces expose subject-key deletion. .NET, TypeScript, and Elixir expose the kernel operation that permits a new encryption key afterward: AllowNewEncryptionKeyFor, allowNewEncryptionKeyFor, and Chronicle.Compliance.allow_new_encryption_key_for/2, respectively. Elixir returns {:error, :not_supported} from a kernel that lacks the operation.
Kotlin and Java do not expose that operation through their client compliance APIs. Workflows that need it must use another supported administrative route or client; deleting a key does not itself authorize a replacement.
Sources: .NET PII API, TypeScript PII API, JVM compliance API, Java bridges, Elixir compliance.
Reactor replay and delivery identity
Section titled “Reactor replay and delivery identity”Replay eligibility
Section titled “Replay eligibility”| Client | Registration and handler behavior |
|---|---|
| .NET | Reactors are replayable unless the class has [OnceOnly]. Method-level [OnceOnly] skips that handler during replay; [Replay] selects a replay handler. |
| Kotlin and Java | Kotlin registration and dispatch use @OnceOnly and @Replay; Java reactors go through the same JVM registration. |
| TypeScript | Method-level @onceOnly() skips that handler during replay; @replay() selects a replay handler. Class-level @onceOnly() sets IsReplayable: false at registration, but the proto3 encoder leaves a false value out, so the kernel registers the reactor as replayable and still replays it. The kernel honors class-level @onceOnly() only once the client also sends IsNotReplayable: true, tracked in Chronicle.TypeScript#236. |
| Elixir | Reactors are replayable unless the module sets once_only: true, which registers IsNotReplayable: true. once_only: [EventModule, ...] skips those events during replay. The event context carries replaying?, and an optional handle_replay/2 is called instead of handle/2 for replayed events. Optional replay-begin/end and partition-replay callbacks report lifecycle transitions. |
These differences follow from registration and dispatch code, together with the kernel’s replay guards. Do not treat an available lifecycle callback as proof that the client’s registered reactors are replayable.
Replay exclusion is not exactly-once delivery. Recovering a failed partition can deliver an event again as an ordinary observation. A side effect still needs idempotency appropriate to the external system.
Sources: .NET registration, JVM registration, JVM handlers, TypeScript reactors, Elixir handlers, Elixir dispatch, kernel replay guards.
Delivery identity
Section titled “Delivery identity”.NET supplies a ReactorDelivery handler parameter with a stable Id (of type DeliveryId) suitable for an application’s receipt key. Elixir supplies an equivalent on the event context: delivery_id, built from the reactor id, event store, namespace, event sequence id, event source id, and sequence number, and identical on every redelivery. Kotlin, Java, and TypeScript have no equivalent client-provided delivery identity.
In those three clients, define an idempotency key that distinguishes the event store, namespace, event sequence, reactor, and event position; do not use a sequence number alone across stores or sequences. A key identifies the delivery—it does not make the external effect atomic with recording a receipt.
Sources: .NET delivery identity, JVM event context, TypeScript event context, and the Elixir event context.
Event seeding
Section titled “Event seeding”| Client | Default target and duplicate handling | Seeder callback failure |
|---|---|---|
| .NET | For/ForEventSource entries are stored globally per event store and applied once to every existing namespace and to each namespace created later. Each client declares discovered globals authoritatively per program identifier, once per event store name per connection lifetime, including empty sets. Explicitly queued globals remain append-only. ForNamespace(...) targets only the named namespace. The kernel tracks entry occurrences by source, event type, content, tags, and routing. | Activation and callback failures are logged. If any seeder fails, no discovered global declaration is sent, preserving the previous declaration. Namespace-scoped and manually queued entries still register; the next registration retries discovery. |
| TypeScript | for/forEventSource entries use the kernel’s append-only global seeding path and entry tracking. Authoritative declarations are tracked in #4871. | Logged as a warning and discovery continues; entries added before the failure are not removed. |
| Kotlin and Java | Entries target the selected namespace, defaulting to the client’s namespace. Uses kernel entry tracking, but does not send seed tags. | A callback exception propagates. |
| Elixir | Entries without an explicit for_namespace target the client’s namespace; global entries are applied by the kernel to every namespace it knows about. Entries go to the kernel seeding service, with event type tags, and use its entry tracking, so a new entry added to an already seeded source is applied. There is no authoritative per-program declaration. | A seeder that raises is logged and contributes none of its entries; other seeders still run. A rejected request is retried every five seconds. |
A seed callback completing is not the same as successful registration; check the owning client’s registration outcome separately.
In .NET, opening a store for a new namespace creates it. The kernel then applies stored global seeds asynchronously,
usually before the application’s first write, but there is no ordering guarantee. Do not rely on that timing;
put development or sample data in ForNamespace(...). With an upgraded .NET client and kernel, removing a discovered
global seed replaces the program’s stored declaration, so namespaces created afterwards no longer receive it.
Existing namespaces keep all events already appended. Set distinct, stable ChronicleOptions.ProgramIdentifier values
when several programs share a store. The first authoritative declaration clears legacy unowned entries, but retains
other programs’ declarations and explicit additions. Identical seeds shared across declarations or explicit additions
use the largest occurrence count in a single program’s group, not the sum. Clearing one declaration leaves seeds
still offered by another group available to future namespaces. Older clients and older kernels keep append-only behavior.
See clear stale development seeds.
Sources: .NET seeding, kernel seeding, JVM seeding, TypeScript seeding, Elixir seeding.
For the shared workflows, see event concurrency, PII, reactor delivery identity, and event seeding.