Skip to content

Appending

Appending adds a single event to an event sequence. The event log is the default sequence, but you can also append to any custom sequence your system defines.

Before the event is sent, the .NET client adds any cross-cutting properties to its content. The Chronicle kernel does not add them. When it receives the event, it:

  • Validates the event content against its registered JSON schema
  • Associates the event with its event source
  • Assigns the next sequence number
  • Persists the event and metadata atomically
  • Updates the sequence state

The append returns an operation result you can use to detect schema errors, concurrency violations, constraint violations, and other append-time problems. A result that reports a schema, constraint, or concurrency violation means the event was rejected and not appended, so you can correct the problem and append again without duplicating it. A result that reports errors is different: an infrastructure failure can happen after the event was stored, so the outcome is unknown — and so is it when the call fails without a result, such as a timeout or a dropped connection. Appending again blindly after an unknown outcome can store the event twice. Make that retry safe with an expected sequence number or a constraint.

AppendOutcomeUnknown means the event or batch may or may not be stored. Chronicle could not confirm the attempted slots by reading them back. Do not retry blindly: doing so can store the events twice.

Read the event source’s events and look for the attempt’s correlation id in their context. Append again only if those events are absent. A timeout or dropped connection without a result is also an unknown outcome, not proof that nothing was stored.

Retry with an idempotency key instead, so the retry is deduplicated.

When an idempotency constraint or concurrency check identifies a previously stored event, compare its content before treating the attempted append as a duplicate. Do not reserialize a deserialized event: that can lose subtype properties and cannot distinguish erased personal data from an intact empty value.

In the .NET client, call eventSequence.Prepare(event) before attempting the append. Pass the returned PreparedEvent to AppendPrepared(eventSourceId, preparedEvent) and keep it while resolving the outcome. Preparation uses the store’s event serializer, including polymorphic types and additional-information providers. Providers run once; append and verification reuse the same serialized snapshot even if the original object changes. To append prepared content through a registered event source, call AppendPrepared<TEventSource>(eventSourceId, preparedEvent, eventStream, eventStreamId); it resolves routing and concurrency scope the same way as Append<TEventSource>.

Call eventSequence.VerifyContent(sequenceNumber, preparedEvent, eventSourceId) with the exact sequence number identified by your idempotency mechanism. Use the same sequence instance that prepared the event. The optional event source argument prevents comparing an event belonging to another source.

ResultMeaning
EqualThe event type and complete released content of every stored generation match the attempted append’s migrated representation on this backend, without detected conversion loss.
DifferentThe event type or complete released content differs.
UnavailableChronicle cannot establish equality or difference from complete content. Do not treat this as a duplicate.

Comparison runs in the kernel, not on client CLR objects or encrypted event hashes. Protected values are masked before the attempted content goes through the same registered upcasts and downcasts as append. Every stored generation must have a migrated counterpart and a schema, and every conversion must preserve its declared values. Matching just the requested generation is insufficient: a downcast can discard the only difference between two events.

Each generation goes through the backend’s append serialization. Stored documents are compared directly after strict release; they are not re-normalized through the schema. Provider overwrites of declared properties participate in comparison; undeclared properties discarded by append do not. Protected containers remain opaque during conversion, so their original nulls and undeclared members participate in comparison. Append migrates plaintext and protects each generation separately, but verification keeps protected values opaque. It only trusts a migration that carries a protected value across whole: a plain rename or move. If a migration reads a protected value in any other way (split, combine, value map, default, or any expression), copies it, moves it to an unprotected property, or produces a value it cannot have come from, the result is Unavailable, never Equal or Different, even when the value appears to survive. Attempted or stored content that contains the text chronicle-verification: is also Unavailable. Providers never run again during verification, and verification does not invent missing migrations.

Object property order does not matter; array order, property presence, and string values do. Numbers are compared exactly, ignoring only equivalent numeric spelling (such as 1.0 and 1). Date/time offsets remain part of the stored string: equivalent instants with different offsets are not equal. In-memory storage retains decimal precision. MongoDB’s decimal-to-double conversion is checked for loss: if the attempted value changes at that precision, verification returns Unavailable, never Equal. The same rule applies to other unrepresentable values, including enum values a backend would replace with a default. Schema defaults and omitted nulls follow append behavior. Equal describes the stored representation, not byte-for-byte equality of the original request.

Missing events, missing generations or schemas, redacted events, erased keys, unsupported protection handlers or storage providers, and failed releases produce Unavailable. Revised events always return Unavailable, even if the requested generation was not itself revised. SQL storage currently returns Unavailable for all content verification: it overwrites revisions without retaining revision history, so historical revisions cannot be ruled out. Backends must explicitly support reliable revision tracking before verification can compare content. Protected values also produce Unavailable when their schema does not identify a single non-null type, or when multiple protection handlers apply: Chronicle cannot safely recover the original value in those cases. Ordinary reads keep their existing empty-value behavior after erasure.

Prepared content is plaintext, just like an ordinary append request. Verification sends it in a request body and returns only the verdict, never the released stored content. Retain the snapshot only as long as needed. This checks content, not causation, tags, routing metadata, or whether the event belongs to the same business operation; your idempotency mechanism must establish that identity. Transport and storage failures can still fail the call and must not be interpreted as equality.

An idempotency key makes a retry safe. Append with the same key again and Chronicle writes nothing a second time. Use one when the outcome of an append is unknown, for example after a timeout, a dropped connection, or a restart in the middle of an operation.

In the .NET client, call AppendOnce with an IdempotencyKey (or AppendOnceWithNamedTags when the event carries named tags). It takes the same optional arguments as Append.

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
[EventType]
public record OrderPlacedOnce(string CustomerId, decimal Total);
public class OnceCheckoutService(IEventLog eventLog)
{
public async Task PlaceOrder(string orderId, string customerId, decimal total)
{
if (!eventLog.SupportsIdempotentAppends)
{
// The connected kernel is too old; a keyed append would throw IdempotentAppendsNotSupported.
return;
}
// The key comes from the business operation, so a retry builds the same key.
var key = new IdempotencyKey($"place-order:{orderId}");
var result = await eventLog.AppendOnce(
orderId,
new OrderPlacedOnce(customerId, total),
key);
if (result.HasIdempotencyConflicts)
{
// The key already identifies a different event. Do not retry with it.
var conflict = result.IdempotencyConflicts.First();
Console.WriteLine($"{conflict.Key} is held by #{conflict.ExistingSequenceNumber}: {conflict.Reason}");
return;
}
switch (result.Outcome)
{
case AppendOutcome.Appended:
break; // This call wrote the event.
case AppendOutcome.AlreadyAppended:
case AppendOutcome.AlreadyAppendedAfterErasure:
break; // An earlier attempt already wrote it; nothing was written now.
}
}
}

View C# snippet source on GitHub

Create keys explicitly with new IdempotencyKey(value). There is no implicit conversion from a string. A key is 1 to 512 characters, and an empty or longer value throws IdempotencyKeyNotValid. Derive it from the business operation, not from a random value, so every retry of the same operation builds the same key.

AppendResult.Outcome reports what the call did. An unkeyed append always reports Appended.

OutcomeMeaning
AppendedThis call wrote the event.
AlreadyAppendedAn event with the same key and the same content is already in the sequence. Nothing was written.
AlreadyAppendedAfterErasureAn event with the same key was written earlier and has since been erased. Nothing was written.

WasAlreadyAppended is true for the last two.

A key that already identifies a different event is refused, not overwritten. The result reports it in IdempotencyConflicts, and HasIdempotencyConflicts is true. Check it explicitly; do not rely on IsSuccess alone. Each IdempotencyKeyConflict carries the key, the sequence number of the event that holds it, the stored event type, the generation you requested, and a reason:

ReasonMeaning
ContentThe key was used for an event with different content.
GenerationThe key was used for the same event type in a different generation.
UnverifiableThe stored event cannot be compared with this one.

Do not retry with a refused key. Either the two appends are different operations that need different keys, or the stored event is the one you meant to write.

Idempotent appends need a kernel that supports them. A keyed append never falls back to an unkeyed one, because that would remove the protection you asked for. Against an older kernel, AppendOnce throws IdempotentAppendsNotSupported and nothing is sent. Check IEventSequence.SupportsIdempotentAppends first when an older kernel might be connected.

Keys are refused with IdempotentAppendsNotSupported, and nothing is appended, in two cases:

  • Events staged in a unit of work.
  • Appends that carry an effect receipt.

The Kotlin, TypeScript, and Elixir clients do not have a keyed append API yet. See where the clients differ.

Appending returns an AppendResult that describes whether the operation succeeded and includes details about what went wrong when it fails. It can surface schema validation errors, concurrency violations, constraint violations, and other append-time problems. See Concurrency and Constraints for more on these cases.

Chronicle validates every event’s content against its registered JSON schema before persisting it. If the content does not conform to the schema — for example, a required property is missing or has the wrong type — the append fails and each problem comes back as a constraint violation of type Schema, with the offending path in its details — not as an error. In the .NET client that is an entry in AppendResult.ConstraintViolations while AppendResult.Errors stays empty, so check IsSuccess or the violations rather than the errors alone.

using Cratis.Chronicle.Events.Constraints;
var result = await eventLog.Append(eventSourceId, new OrderPlaced(customerId, total));
foreach (var violation in result.ConstraintViolations.Where(v => v.ConstraintType == ConstraintType.Schema))
{
Console.WriteLine($"Schema violation at {violation.Details["path"]}: {violation.Message}");
}

View C# snippet source on GitHub

Schema validation runs before any other checks, so a schema error is returned immediately without touching the event sequence.

Omitted or empty append metadata is stored using these canonical values:

MetadataStored default
Event source typeDefault
Event stream typeAll
Event stream IDDefault

Single appends and batches use the same defaults. Nonempty values you supply are preserved exactly. In the .NET client, the resolved values also describe the append notification and the inputs to the concurrency strategy.

Earlier 18.x kernels could store empty metadata for single appends. Upgrading corrects new writes; it does not rewrite historical events or normalize their read-back metadata. If you compare historical routes, account for those legacy empty values only where your application intended the default route. Explicit metadata filters can still distinguish those older events from events carrying canonical defaults.

By default, Chronicle assigns the current server time as the event’s Occurred timestamp when it persists the event. Some clients expose an explicit timestamp override for import and replay scenarios.

var result = await eventLog.Append(
eventSourceId,
new OrderPlaced(customerId, total),
occurred: DateTimeOffset.Parse("2024-01-15T10:30:00Z")
);

View C# snippet source on GitHub

Appending is lightweight and intended for single domain decisions that produce one event. When your decision spans multiple event sources, use concurrency scopes to enforce the boundary while keeping sequences independent. See Concurrency for details.

using Cratis.Chronicle.Events;
[EventType]
public record OrderPlaced(string CustomerId, decimal Total);
public class CheckoutService(IEventLog eventLog)
{
public async Task PlaceOrder(OrderId orderId, string customerId, decimal total)
{
var result = await eventLog.Append(
orderId,
new OrderPlaced(customerId, total)
);
if (!result.IsSuccess)
{
// Decide whether to retry or surface a conflict to the caller.
}
}
}

View C# snippet source on GitHub