From script to stage · Part 01: Events are facts
Cratis: from script to stage, and the long run · Part 01 of 26
A table row holds the last thing that happened to it. Change an author’s name in a row and the old name is gone, along with the request that changed it, the person who sent it and the reason. In an event-sourced system the change is a new fact at the end of a log. It gets a sequence number that never changes. The request, the identity behind it and the chain of causes are stored with it, and nothing recorded before it moves.
That difference decides how you name an event and what you put in it. Cratis: from script to stage, and the long run follows one small feature through all of Cratis: a signed-in user registers an author, and author names are unique within the organization. The feature records a single fact, AuthorRegistered, and everything else in its life is built on that fact.
The fact has to hold up over time. In month three someone asks to be forgotten, and the author’s name is personal data. In month nine a view stops updating. In year two the event needs a new field and a second service wants to hear about new authors. What goes into AuthorRegistered today decides how each of those goes.
Intent, fact and state
Section titled “Intent, fact and state”Event sourcing changes the question a system answers. A CRUD table tells you what a row looks like now. An event log tells you what happened, in what order and why, and the audit trail is the model itself. Cratis sees event sourcing as the default architecture for information systems, and Chronicle still leaves room for the table where history carries no meaning.
Three things in the author feature look alike and do different jobs. RegisterAuthor is a command, a request that can be refused because the name is taken or the user isn’t allowed to make it. AuthorRegistered is an event, the record that a registration did happen. The Author row that a list reads from is a read model, derived from the events and rebuildable from them. Because of that split, a wrong view gets fixed by correcting the projection and replaying the events, and a side effect such as sending an email lives in a reactor, outside the projection that a replay rebuilds.

A command asks, an event records, a read model is derived.
The names carry the difference. In this pair, both records have the Chronicle attribute:
using Cratis.Chronicle.Events;
public record Address(string Street, string City);
// A fact that happened[EventType]public record AddressChanged(Address Address);
// An intent (that's a command) or a state blob (that's a read model) — not an event[EventType]public record UpdateAddress(Address Address);import io.cratis.chronicle.events.EventType
data class Address(val street: String, val city: String)
// A fact that happened@EventTypedata class AddressChanged(val address: Address)
// An intent (that's a command) or a state blob (that's a read model) — not an event@EventTypedata class UpdateAddress(val address: Address)import io.cratis.chronicle.events.EventType;
record Address(String street, String city) {}
// A fact that happened@EventTyperecord AddressChanged(Address address) {}
// An intent (that's a command) or a state blob (that's a read model) — not an event@EventTyperecord UpdateAddress(Address address) {}import { eventType } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
class Address { @field(String) readonly street: string; @field(String) readonly city: string;
constructor(street: string, city: string) { this.street = street; this.city = city; }}
// A fact that happened@eventType()class AddressChanged { @field(Address) readonly address: Address;
constructor(address: Address) { this.address = address; }}
// An intent (that's a command) or a state blob (that's a read model) — not an event@eventType()class UpdateAddress { @field(Address) readonly address: Address;
constructor(address: Address) { this.address = address; }}defmodule MyApp.Address do defstruct [:street, :city]end
# A fact that happeneddefmodule MyApp.Events.AddressChanged do use Chronicle.Events.EventType, id: "address-changed"
defstruct [:address]end
# An intent (that's a command) or a state blob (that's a read model) — not an eventdefmodule MyApp.Events.UpdateAddress do use Chronicle.Events.EventType, id: "update-address"
defstruct [:address]endBoth records compile and both register. UpdateAddress gives itself away by its name, because it asks for something that may or may not happen. Chronicle can’t tell an intent from a fact by looking at the payload, so the name is where the distinction lives.
A fact also outlives the moment it was produced. A notification goes out once, to whoever is listening, and then it’s gone. A recorded fact stays in the log, so a view built from it can be built again, and a view you only think of next year can be built from events recorded today.

A notification is gone once sent; a recorded fact can be read again.
Name it in the past tense
Section titled “Name it in the past tense”An event’s name is a past-tense verb phrase in the language of the domain: OrderPlaced, AddressChanged, AuthorRegistered. CreateOrder is a command and OrderState is a model. If you can’t name something in the past tense, it isn’t an event yet. The rule sounds cosmetic until you read a log a year later, where every name is either a statement about the business or a puzzle.
Each event captures one meaningful change. The kitchen-sink CustomerUpdated, carrying every field of the customer with most of them unchanged, forces each consumer to work out which change actually happened. AddressChanged says the customer moved. A CRUD habit pushes toward mirroring columns. Modeling the decision the business cares about gives every consumer of the event something precise to work from.
AuthorRegistered passes that test. Registering an author is something a librarian decides to do. Later changes keep to the same rule. A typo in the registered name is a mistake in what was recorded, and Chronicle corrects it with a revision. A pen name the author adopts a year later is something new that happened, and it gets an event and a name of its own.
A nullable property is two facts
Section titled “A nullable property is two facts”Nullable properties are where the rule gets strict. An event records what was true at the moment it happened. A nullable property says that part of the fact sometimes didn’t happen, which is a contradiction in the event’s own terms. When a value is sometimes present and sometimes absent, there are two facts, and each gets its own event:
using Cratis.Chronicle.Events;using Cratis.Concepts;
public record OrderId(Guid Value) : EventSourceId<Guid>(Value);public record CustomerId(Guid Value) : ConceptAs<Guid>(Value);public record Money(decimal Amount, string Currency);
// Nullable smell — "sometimes there's a discount, sometimes not"[EventType]public record OrderPlacedWithNullableDiscount( CustomerId CustomerId, Money Total, Money? Discount);
// Two facts[EventType]public record OrderPlaced(CustomerId CustomerId, Money Total);
[EventType]public record DiscountApplied(Money Amount);import io.cratis.arc.concepts.ConceptAs as ArcConceptAsimport io.cratis.chronicle.concepts.ConceptAs as ChronicleConceptAsimport io.cratis.chronicle.events.EventTypeimport java.math.BigDecimalimport java.util.UUID
data class OrderId(private val id: UUID) : ArcConceptAs<UUID>, ChronicleConceptAs<UUID> { override fun value(): UUID = id override val value: UUID get() = id}
data class CustomerId(private val id: UUID) : ArcConceptAs<UUID>, ChronicleConceptAs<UUID> { override fun value(): UUID = id override val value: UUID get() = id}
data class Money(val amount: BigDecimal, val currency: String)
// Nullable smell — "sometimes there's a discount, sometimes not"@EventTypedata class OrderPlacedWithNullableDiscount( val customerId: CustomerId, val total: Money, val discount: Money?)
// Two facts@EventTypedata class OrderPlaced(val customerId: CustomerId, val total: Money)
@EventTypedata class DiscountApplied(val amount: Money)import io.cratis.arc.concepts.ConceptAs;import io.cratis.chronicle.events.EventType;
import java.math.BigDecimal;import java.util.UUID;
record OrderId(UUID value) implements ConceptAs<UUID>, io.cratis.chronicle.concepts.ConceptAs<UUID> { @Override public UUID getValue() { return value; }}
record CustomerId(UUID value) implements ConceptAs<UUID>, io.cratis.chronicle.concepts.ConceptAs<UUID> { @Override public UUID getValue() { return value; }}
record Money(BigDecimal amount, String currency) {}
// Nullable smell — "sometimes there's a discount, sometimes not"@EventTyperecord OrderPlacedWithNullableDiscount( CustomerId customerId, Money total, Money discount) {}
// Two facts@EventTyperecord OrderPlaced(CustomerId customerId, Money total) {}
@EventTyperecord DiscountApplied(Money amount) {}import { eventType } from '@cratis/chronicle';import { ConceptAs, field, Guid } from '@cratis/fundamentals';
class OrderId extends ConceptAs<Guid> { static readonly valueType = Guid;
constructor(value: Guid) { super(value); }}
class CustomerId extends ConceptAs<Guid> { static readonly valueType = Guid;
constructor(value: Guid) { super(value); }}
class Money { @field(Number) readonly amount: number; @field(String) readonly currency: string;
constructor(amount: number, currency: string) { this.amount = amount; this.currency = currency; }}
// Nullable smell — "sometimes there's a discount, sometimes not"@eventType()class OrderPlacedWithNullableDiscount { @field(CustomerId) readonly customerId: CustomerId; @field(Money) readonly total: Money; @field(Money) readonly discount?: Money;
constructor(customerId: CustomerId, total: Money, discount?: Money) { this.customerId = customerId; this.total = total; this.discount = discount; }}
// Two facts@eventType()class OrderPlaced { @field(CustomerId) readonly customerId: CustomerId; @field(Money) readonly total: Money;
constructor(customerId: CustomerId, total: Money) { this.customerId = customerId; this.total = total; }}
@eventType()class DiscountApplied { @field(Money) readonly amount: Money;
constructor(amount: Money) { this.amount = amount; }}defmodule MyApp.OrderId do use Chronicle.Concept, type: :uuid, event_source_id: trueend
defmodule MyApp.CustomerId do use Chronicle.Concept, type: :uuidend
defmodule MyApp.Money do defstruct [:amount, :currency]end
# Nullable smell — "sometimes there's a discount, sometimes not"defmodule MyApp.Events.OrderPlacedWithNullableDiscount do use Chronicle.Events.EventType, id: "order-placed-with-nullable-discount"
defstruct customer_id: %MyApp.CustomerId{}, total: %MyApp.Money{}, discount: nilend
# Two factsdefmodule MyApp.Events.OrderPlaced do use Chronicle.Events.EventType, id: "order-placed"
defstruct customer_id: %MyApp.CustomerId{}, total: %MyApp.Money{}end
defmodule MyApp.Events.DiscountApplied do use Chronicle.Events.EventType, id: "discount-applied"
defstruct amount: %MyApp.Money{}endWith two events, a projection that shows discounts listens for DiscountApplied and never has to ask whether a null meant “no discount” or “we forgot to send it”. Chronicle’s .NET code analysis flags a nullable event property with rule CHR0012 as a warning, and a nullable property is still allowed. Data collection is the exception. When a form lets someone leave their middle name empty, the event records exactly what was collected, and null is the true value.
In an Arc application, a command handler returns the events and Arc’s Chronicle integration appends them together, with DiscountApplied included only when the order has a discount:
using Cratis.Arc.Commands.ModelBound;
[Command]public record PlaceOrder(OrderId Id, CustomerId CustomerId, Money Total, Money? Discount){ public IEnumerable<object> Handle() { var events = new List<object> { new OrderPlaced(CustomerId, Total) };
if (Discount is { } discount) { events.Add(new DiscountApplied(discount)); }
return events; }}import io.cratis.arc.artifacts.Commandimport io.cratis.arc.artifacts.CommandKeyimport io.cratis.arc.commands.CommandResponseValues
@Commanddata class PlaceOrder( @CommandKey val id: OrderId, val customerId: CustomerId, val total: Money, val discount: Money?) { fun handle(): CommandResponseValues = CommandResponseValues( listOfNotNull( OrderPlaced(customerId, total), discount?.let { DiscountApplied(it) } ) )}import io.cratis.arc.artifacts.Command;import io.cratis.arc.artifacts.CommandKey;import io.cratis.arc.commands.CommandResponseValues;import org.jetbrains.annotations.Nullable;
@Commandpublic record PlaceOrder(@CommandKey OrderId id, CustomerId customerId, Money total, @Nullable Money discount) { public CommandResponseValues handle() { var response = CommandResponseValues.builder().add(new OrderPlaced(customerId, total));
if (discount != null) { response.add(new DiscountApplied(discount)); }
return response.build(); }}From Arc for TypeScript, an early source preview whose packages aren’t on npm yet:
import { command, key, optional } from '@cratis/arc.core';import { field } from '@cratis/fundamentals';
@command()export class PlaceOrder { @field(OrderId) @key() id!: OrderId; @field(CustomerId) customerId!: CustomerId; @field(Money) total!: Money; @field(Money) @optional() discount?: Money;
handle() { const events: object[] = [new OrderPlaced(this.customerId, this.total)];
if (this.discount) { events.push(new DiscountApplied(this.discount)); }
return events; }}Arc for Elixir doesn’t exist; use the Chronicle Elixir client directly.
defmodule MyApp.PlaceOrder do alias MyApp.Events.{DiscountApplied, OrderPlaced}
def place_order(%MyApp.OrderId{} = order_id, customer_id, total, discount) do events = [%OrderPlaced{customer_id: customer_id, total: total}]
events = if discount do events ++ [%DiscountApplied{amount: discount}] else events end
Chronicle.append_many(order_id.value, events) endendCarry what was true then
Section titled “Carry what was true then”An event holds the data that was true at the moment it occurred, captured by value. It doesn’t point at mutable state that might change later, and it doesn’t carry what a consumer can work out for itself. Read it in five years with no other context, and it should still make sense on its own. That five-year test is worth the minute it takes before an event type is committed, because the type will outlive the code that first appended it.
For AuthorRegistered, the name travels by value, exactly as it was registered. The books the author goes on to write don’t belong in it, because at the moment of registration those facts hadn’t happened yet, and each will be recorded when it does.
Whose fact it is: event source and event type
Section titled “Whose fact it is: event source and event type”Every event belongs to an event source, the thing its facts are about: a person, an order, an author. The event source binds a stream of facts together, much as a primary key binds a row. In the .NET client, EventSourceId is a concept over string that can also be constructed from a Guid. For the author feature it’s the author’s id, and that id turns up again and again. Projections key their read models by it unless told otherwise, and when processing fails, Chronicle retries the events of that one event source while the others carry on.
An event is identified by its event type, not by its CLR type. [EventType] takes an optional id, which defaults to the type name, and a generation that starts at 1. Because the default id is the type name, renaming the class changes the event’s identity, so a long-lived event deserves an explicit id.
A client registers its event types with Chronicle before it appends, and each generation registers its own JSON schema. Every append is validated against that schema before anything else happens. A payload that doesn’t match comes back as a constraint violation of type Schema, with the path of the offending property, and the call doesn’t throw. When an event’s shape has to change, the new shape becomes a new generation stored beside the old one, and Year two covers how the two are read together.
The log and its numbers
Section titled “The log and its numbers”Events live in event sequences. A sequence is ordered and append-only, and each event gets the next number, which never changes afterwards. The sequence you’ll append to almost every time is the event log, IEventLog. Chronicle also keeps outbox and inbox sequences for subscriptions between event stores, which Reacting to facts takes apart.
Anything that processes events remembers how far it has come, and that position is a sequence number. A projection that has seen everything up to #41 knows its next event is #42, and a restart or a new deployment doesn’t change what #42 is.

Each event source has its own facts, and every fact has one number in the log.
Appending is one call on the event log, here with the OrderPlaced from above:
using Cratis.Chronicle.EventSequences;
public class Checkout(IEventLog eventLog){ public Task<AppendResult> PlaceOrder(OrderId orderId, CustomerId customerId, Money total) => eventLog.Append(orderId, new OrderPlaced(customerId, total));}import io.cratis.chronicle.eventSequences.AppendResultimport io.cratis.chronicle.eventSequences.IEventLog
class Checkout(private val eventLog: IEventLog) { suspend fun placeOrder(orderId: OrderId, customerId: CustomerId, total: Money): AppendResult = eventLog.append(orderId.value.toString(), OrderPlaced(customerId, total))}import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.IEventLog;import io.cratis.chronicle.java.BlockingEventSequence;
class Checkout { private final BlockingEventSequence eventLog;
Checkout(IEventLog eventLog) { this.eventLog = new BlockingEventSequence(eventLog); }
AppendResult placeOrder(OrderId orderId, CustomerId customerId, Money total) { return eventLog.append(orderId.value().toString(), new OrderPlaced(customerId, total)); }}import { AppendResult, IEventLog } from '@cratis/chronicle';
class Checkout { constructor(private readonly eventLog: IEventLog) {}
placeOrder(orderId: OrderId, customerId: CustomerId, total: Money): Promise<AppendResult> { return this.eventLog.append(orderId.toString(), new OrderPlaced(customerId, total)); }}defmodule MyApp.Checkout do def place_order(%MyApp.OrderId{} = order_id, customer_id, total) do Chronicle.append(order_id.value, %MyApp.Events.OrderPlaced{customer_id: customer_id, total: total}) endendBehind the call, Chronicle does five things in order:
- It validates the content against the registered schema.
- It associates the event with its event source.
- It assigns the next sequence number.
- It persists the event and its metadata atomically.
- It updates the sequence’s state.
Metadata you leave out gets a stored default: event source type Default, event stream type All and event stream id Default. Every client can supply the time the event occurred. Otherwise the server stamps it, which is what you want in almost every case. Setting Occurred yourself bypasses the server’s clock and can break anything that relies on time ordering, so it belongs to imports and replays of existing history.
Cross-cutting properties cover data that belongs on every event and in no domain method. A class implementing ICanProvideAdditionalEventInformation is discovered automatically, and it can add a tenant id to each append without the code that appends ever touching it. In .NET, the client adds cross-cutting properties before the event is sent.
The result deserves a check by the caller, which is why PlaceOrder returns it. An append that reports a schema, constraint or concurrency violation was rejected, and the event wasn’t stored. An append that reports an infrastructure error, or that fails on a timeout or a dropped connection, has an unknown outcome, and appending again can store the event twice. A retry becomes safe when the append carries an expected sequence number or when a constraint would reject the duplicate. The [Unique] rule on an author’s name is one such constraint, and Rules that hold when you write covers the rest. Inside Chronicle follows the same append through the kernel.
What travels with every event
Section titled “What travels with every event”An event arrives at whatever reads it together with its context. In the .NET, JVM and TypeScript clients that’s EventContext:
public record EventContext( EventType EventType, EventSourceType EventSourceType, EventSourceId EventSourceId, EventStreamType EventStreamType, EventStreamId EventStreamId, EventSequenceNumber SequenceNumber, DateTimeOffset Occurred, EventStoreName EventStore, EventStoreNamespaceName Namespace, CorrelationId CorrelationId, IEnumerable<Causation> Causation, Identity CausedBy, IEnumerable<Tag> Tags, EventHash Hash, EventObservationState ObservationState = EventObservationState.Initial, Subject Subject = default!);data class EventContext( val sequenceNumber: Long, val eventSourceId: String, val eventType: EventTypeDescriptor, val occurred: Instant, val correlationId: UUID, val causedBy: Identity, val eventSourceType: String = "", val eventStreamType: String = "", val eventStreamId: String = "", val eventStore: String = "", val namespace: String = "", val causation: List<Causation> = emptyList(), val tags: List<String> = emptyList(), val hash: String = "", val observationState: EventObservationState = EventObservationState.none, val subject: String = "")Java uses the same class as Kotlin, io.cratis.chronicle.events.EventContext, and reads the fields through getters such as getOccurred().
export interface EventContext { readonly sequenceNumber: bigint; readonly eventSourceId: string; readonly eventStore?: string; readonly namespace?: string; readonly eventSourceType?: string; readonly eventStreamType?: string; readonly eventStreamId?: string; readonly subject?: string; readonly hash?: string; readonly causedBy?: Identity; readonly observationState?: number; readonly eventType: EventType; readonly occurred: Date; readonly correlationId: string; readonly causation: ReadonlyArray<CausationEntry>; readonly tags: ReadonlyArray<Tag>;}The Elixir client passes the event context to handle/2 as a map with the keys event_source_id, sequence_number, occurred, event_store and namespace.
Most of the fields place the event in the log and in time, down to the event store and namespace it lives in. Subject defaults to the event source id. The namespace matters for the author feature, because with Arc’s Chronicle integration each tenant gets a namespace of its own, with its own events and observers.
The code that appends can set some of these fields itself. An order imported from an older system keeps the time it was placed, gets an event source type and a stream type, and carries a tag:
public class OrderImport(IEventLog eventLog){ public Task<AppendResult> ImportOrder( OrderId orderId, CustomerId customerId, Money total, DateTimeOffset placedAt) => eventLog.Append( orderId, new OrderPlaced(customerId, total), eventSourceType: "Order", eventStreamType: "Checkout", tags: ["imported"], occurred: placedAt);}class OrderImport(private val eventLog: IEventLog) { suspend fun importOrder( orderId: OrderId, customerId: CustomerId, total: Money, placedAt: Instant ): AppendResult = eventLog.append( orderId.value.toString(), OrderPlaced(customerId, total), AppendOptions( eventSourceType = "Order", eventStreamType = "Checkout", tags = listOf("imported"), occurred = placedAt ) )}class OrderImport { private final BlockingEventSequence eventLog;
OrderImport(IEventLog eventLog) { this.eventLog = new BlockingEventSequence(eventLog); }
AppendResult importOrder(OrderId orderId, CustomerId customerId, Money total, Instant placedAt) { var options = new AppendOptionsBuilder() .eventSourceType("Order") .eventStreamType("Checkout") .tag("imported") .occurred(placedAt) .build();
return eventLog.append(orderId.value().toString(), new OrderPlaced(customerId, total), options); }}class OrderImport { constructor(private readonly eventLog: IEventLog) {}
importOrder(orderId: OrderId, customerId: CustomerId, total: Money, placedAt: Date) { return this.eventLog.append(orderId.toString(), new OrderPlaced(customerId, total), { sourceType: 'Order', streamType: 'Checkout', tags: ['imported'], occurred: placedAt }); }}defmodule MyApp.OrderImport do def import_order(%MyApp.OrderId{} = order_id, customer_id, total, placed_at) do Chronicle.append( order_id.value, %MyApp.Events.OrderPlaced{customer_id: customer_id, total: total}, event_source_type: "Order", event_stream_type: "Checkout", tags: ["imported"], occurred: placed_at ) endendThree of the fields are set once for an operation and then flow into every append made during it, and together they answer who did this and why. The correlation id ties together everything that belongs to one logical operation, such as one HTTP request or one handler invocation. When a single registration produces three events, the correlation id is how you find all three.
CausedBy records the identity behind the change: a user, a system or a service, including on-behalf-of chains when a service acts for a person. The display name isn’t part of the fact. Rename an identity and the new name shows for every event it ever caused, because the stable key is what the event stores.
Causation records why. It’s a chain of links from the root action down to the command, and each link is an occurrence time, a type and a set of properties. Your own code can add a link through the causation manager, ICausationManager in .NET:
using Cratis.Chronicle.Auditing;
public class PlaceOrderCausation(ICausationManager causationManager){ public void RecordPlaceOrder(OrderId orderId) => causationManager.Add( "MyApp.Commands.PlaceOrder", new Dictionary<string, string> { ["orderId"] = orderId.Value.ToString() });}import io.cratis.chronicle.auditing.CausationTypeimport io.cratis.chronicle.auditing.causationManager
// Per thread, not per request or coroutine; clear in finally on thread-pooled paths.class PlaceOrderCausation { fun recordPlaceOrder(orderId: OrderId) { causationManager.add(CausationType("MyApp.Commands.PlaceOrder"), mapOf("orderId" to orderId.value.toString())) }}import io.cratis.chronicle.java.CausationManagerJavaBridge;
import java.util.Map;
import static io.cratis.chronicle.auditing.CausationManagerKt.getCausationManager;
// Per thread, not per request or coroutine; clear in finally on thread-pooled paths.class PlaceOrderCausation { void recordPlaceOrder(OrderId orderId) { CausationManagerJavaBridge.add( getCausationManager(), "MyApp.Commands.PlaceOrder", Map.of("orderId", orderId.value().toString())); }}import { causationManager, CausationType } from '@cratis/chronicle';
class PlaceOrderCausation { recordPlaceOrder(orderId: OrderId): void { causationManager.add(new CausationType('MyApp.Commands.PlaceOrder'), { orderId: orderId.toString() }); }}defmodule MyApp.PlaceOrderCausation do alias Chronicle.Auditing.CausationManager
def record_place_order(%MyApp.OrderId{value: order_id}) do CausationManager.add("MyApp.Commands.PlaceOrder", %{order_id: order_id}) endendIn a Cratis application you rarely write that yourself. Arc’s Chronicle integration adds the command link on its own, and in .NET and TypeScript records the command’s name and its property values on every event the command appends. For RegisterAuthor, that puts the submitted name into the causation of AuthorRegistered. Those values are part of the event log. Changing the code later doesn’t remove them, and they aren’t encrypted under the subject’s key. [PII] or [NotAudited] on a top-level command property keeps its value off new causation chains. A nested object is written as compact JSON, and marking a property inside it doesn’t exclude that property. An author’s name is personal data, and Personal data in an event log is about exactly that problem.

What goes in, what Chronicle does, and what’s stored with the event.
No event leaves the log
Section titled “No event leaves the log”An event sequence is append-only. No event is taken out of it and no sequence number changes. A revision is stored beside the original, and a redaction replaces the payload with a marker in the same slot.
When a value was recorded wrongly, a revision stores a corrected payload beside the original. It keeps the same event type, it’s ordered after the original, and it carries the reviser’s causation and identity. Observers see the latest revision, and the original stays in the history. A revision fixes data that was wrong when it was written. When something real changes in the domain, such as a registration being withdrawn, that’s a new event with a name of its own.
Redaction goes further and replaces the payload with an EventRedacted marker while keeping the slot. The sequence number, the event type, the time it occurred, the correlation, the identities that caused it, and the types and times of its causation links all remain. The original causation values, the revision history and the hashes don’t. Copies made before the redaction, such as database oplogs and backups, still hold the original. Keeping the slot matters because every observer’s offset is a sequence number, so the numbers after a redacted event mean exactly what they meant before. Redaction and erasure are covered in Personal data in an event log, and the choice between a reversal, a revision and a redaction in Year two.
Getting events covers reads by event source and event types, and, in the .NET client, reads from the tail and from a checkpoint. The AppendOperations observable emits after every append, successful or not, which is useful when a test or a tool wants to watch what the application writes.
Where the model stops
Section titled “Where the model stops”Event sourcing is a choice per part of a system. A small current-state form where nobody needs the story of its changes is better served by a database table (see when to use event sourcing). Arc works without Chronicle for that case, Chronicle can be used without Arc, and the ecosystem at a glance lists the rest.
Each decision sits in one place. The uniqueness rule sits on the event, [PII] goes on a value’s type, and the tenant header is set once in Arc’s configuration.
The kernel contract is shared across Chronicle’s clients, and some behavior around it lives in the client. Resolving [Subject] at append time happens only in the .NET client, and so does the default concurrency check; the Kotlin/Java, TypeScript and Elixir clients request none by default. Where the clients differ lists the other gaps.
Of all the design decisions in an event-sourced system, the events are the most expensive to change later. A projection can be fixed and replayed. An event type that merged three decisions into one field-by-field update stays in the log for as long as the log exists, and every consumer written against it inherits the guesswork.
- Previous: Cratis: from script to stage, and the long run, the start of the series
- Next: Part 02 — Inside Chronicle