Not an Event Yet¶
Every event stored in EventSourcingDB is a CloudEvent. What your application sends in order to write one is not. It is missing seven of the eleven fields a stored event carries, and if you try to supply them yourself, the database rejects the whole request.
That gap has a name. Internally we call it an event candidate – not an event yet, more like an application to become one. From the outside this looks like pedantry. It is the reason everything else can be trusted. So let's look at what a candidate brings, what the server adds, and why we closed nearly every freedom the CloudEvents standard was generous enough to leave open.
A Format Everyone Can Agree On¶
When services exchange events, the hard part is rarely the transport. It is that every service describes an event slightly differently, so every new integration means another piece of translation code – and the number of those pieces grows with every pairing, not with every service: ten services that all talk to each other need ninety translations, not ten.
CloudEvents exists to make that translation unnecessary. It is an open specification maintained by the Cloud Native Computing Foundation, and it does one thing: it fixes the structure in which an event describes itself. What kind of event this is, where it came from, what it refers to, when it happened, and what the payload contains. Everything else – which protocol carries it, which language reads it – is left to you. Azure Event Grid and Google Eventarc both accept and emit it as a first-class format.
The specification is deliberately transport-agnostic. The same event can travel over HTTP, AMQP, MQTT or Kafka, and each of those bindings only describes how the fields are mapped onto that protocol's headers and body. That independence is a large part of why the standard spread – and, as we will see, it is also the source of the freedoms we had to take away again.
When we built EventSourcingDB, we could have invented our own event format. Every database has some latitude here, and a purpose-built format would have fit our storage engine a little more snugly. We took the standard instead, because an event that outlives the application that wrote it needs to be readable by tools nobody has written yet. A proprietary format makes that a matter of hope. An open one makes it a matter of reading the specification.
What You Send Is Not an Event¶
Now look at what actually goes over the wire when you write one. Following the Writing Events guide, you assemble four fields: the source identifying the application that emits the event, the subject naming the thing the event is about (here one particular book), the type saying what happened, and the data carrying the details:
{
"source": "https://library.eventsourcingdb.io",
"subject": "/books/42",
"type": "io.eventsourcingdb.library.book-acquired",
"data": {
"title": "2001 – A Space Odyssey",
"author": "Arthur C. Clarke",
"isbn": "978-0756906788"
}
}
Compare that against the CloudEvents specification and it does not qualify. There is no specversion, no id, no time, no datacontenttype. Judged as a CloudEvent, this thing is incomplete – and it is not incomplete because we were sloppy about the standard, but because completing it is not the client's job.
The SDKs carry the distinction into their type systems: in C# you construct an EventCandidate, in Elixir an %EventSourcingDB.EventCandidate{}. The MCP server, which lets a language model read and write events for you, describes its input the same way. The name is doing work. It says that what you are holding is a proposal, and that something has to happen to it before it becomes a fact.
There is one exception, and it covers two fields. You may also send traceparent and tracestate, which carry OpenTelemetry context for distributed tracing. Anything beyond those six fields is refused, and the write fails as a whole rather than quietly dropping what it did not understand.
Why a Candidate Cannot Promote Itself¶
The seven missing fields are not busywork we could have delegated to the client to save ourselves the trouble. Each one is something the client is structurally unable to know or unable to be trusted with.
Take id. In EventSourcingDB it is not a random identifier but a position in a global, gap-free sequence across the entire store, starting at zero. No client can know its own position, because that depends on every other write happening at the same moment. Take time. It is the moment the database learned about the event, not the moment your business process thinks something happened – which is exactly why Time Is of the Essence tells you to put business timestamps into your event data instead.
Then there are hash and predecessorhash, which chain every event in the store to its predecessor and make tampering detectable, as Proving Without Revealing works through and Auditing the Event Store puts to use. A chain whose links the sender may bring along is not a chain. The same goes for the signature, which requires the server's signing key by definition.
That leaves specversion and datacontenttype, and those two are constants: 1.0 and application/json. Accepting them from a client would mean accepting that they could say something else, and there is no other value we would store.
You state what happened; the server attests when it learned of it, in what order, and that nothing has changed since. A candidate is an application. An event is a certificate. And whoever issues their own certificate has none.
The Server Fills In the Rest¶
Send the candidate above to a fresh store, then read it back, and this is what comes out:
{
"specversion": "1.0",
"id": "0",
"time": "...",
"source": "https://library.eventsourcingdb.io",
"subject": "/books/42",
"type": "io.eventsourcingdb.library.book-acquired",
"datacontenttype": "application/json",
"data": {
"title": "2001 – A Space Odyssey",
"author": "Arthur C. Clarke",
"isbn": "978-0756906788"
},
"predecessorhash": "0000000000000000000000000000000000000000000000000000000000000000",
"hash": "...",
"signature": null
}
Four fields went in, eleven came out. The id is "0" because this is the first event in the store, which is also why the predecessor hash is all zeros – there is nothing behind it yet. The signature is null because this instance was started without a signing key; configure one and it is filled in. Two values are left as ... here for the same reason the documentation leaves them out: the time is an RFC 3339 timestamp with nanosecond precision taken at the moment of the write, and the hash is 64 hexadecimal characters over that event, so both differ on every run.
And this, finally, is a CloudEvent in the full sense of the specification. Whatever you read out of EventSourcingDB, and every event that reaches you on an observe connection as it is written, carries the complete structure. The candidate has become an event, and from here on it is interoperable with everything that speaks the standard. If you want the field-by-field account, the CloudEvents documentation has it: because every event carries the same standardized shape, your tooling can read our events without a converter in between, which means an event written today is still legible to a system built years from now.
The Freedoms We Took Away¶
Here is the part that surprises people who know the specification well. CloudEvents is deliberately permissive in places, and we took away nearly every one of those choices.
The standard prescribes no format for id beyond it being a string; we make it a gap-free sequence number. It allows any payload format through datacontenttype; we fix it to application/json. It lets data be whatever the content type permits; we require a JSON object at the top level and reject null, arrays and scalars – an empty object is fine, for events whose type alone carries the meaning. Reverse domain notation for type is a convention in the specification; for us it is a rule, so io.eventsourcingdb.library.book-acquired is accepted and book acquired is not.
The subject is the sharpest case. CloudEvents lists it as optional and treats it as free-form; we make it mandatory, because it identifies the event stream the event belongs to, and we require a path that starts with a slash and whose segments hold nothing but letters, digits, underscores and hyphens. A subject with a dot or a space in it is rejected. And where the standard says time should be the moment the occurrence happened, falling back to another time only when that cannot be determined, we always record the moment the write reached us.
Each of these looks like a restriction and works like a guarantee. A format built for messages in transit can afford wiggle room, because a message is read once, soon, by a recipient who was written against the same assumptions. An event store keeps facts for decades and hands them to readers nobody has met yet. Every choice we had left open would eventually have been answered differently by two teams in the same company, and the store would keep both answers forever.
The Question We Get Asked Most Often¶
The question runs like this: could there be a metadata field at the top level, so that application frameworks have somewhere to put correlation IDs, causation IDs and their own bookkeeping? The standard even seems to invite it, since it allows custom extension attributes.
The answer is no, and the reason is the namespace. The specification requires every attribute name to consist of lower-case letters and digits, nothing else, and asks that names not exceed twenty characters. There is no prefix, no vendor segment, no dot to hide behind – every extension attribute in the world shares one flat space, and the specification compares them to custom HTTP headers. Whoever puts a name up there is betting that nothing else will ever claim it, and metadata is the most generic name anyone could pick for that bet.
You lose such a bet quietly, because a collision does not announce itself when you make it. It surfaces years later, in events that are immutable by then, so every one already written keeps the attribute, and the only way out is a migration over the entire store.
And a migration over an event store is not a schema change. Because the events are chained by hash, rewriting even one of them means recomputing every hash after it and signing all of them again – and any hash somebody recorded elsewhere as evidence, in an audit report or a counterparty's system, stops matching. The cheapest moment to avoid that is before the first event is written, which is the moment we are talking about here.
We are aware that this cuts both ways, because we use three extension attributes ourselves: predecessorhash, hash and signature. The difference is not that our names are better. It is that there was nowhere else to put them: a proof that an event has not been altered has to sit on the event itself, outside the payload it protects, and none of the three says anything about your business. For metadata there is somewhere else, and it is the field you already own.
For everything else, the argument is simpler than it looks. If a framework wants to store something, it has a domain reason for wanting it – and anything with a domain reason belongs in data, where you own the namespace and can nest a metadata property beneath it without colliding with anyone. Versioning ends up in the same place for a related reason: CloudEvents has no version attribute at all, so the version travels inside the type, as Versioning Events Without Breaking Everything works through.
From Candidate to Fact¶
So what you send is not an event, and what you get back is. The distance between the two is seven fields, and every one of them is there because you cannot credibly supply it about yourself.
The strictness is not bureaucracy. It is the reason the result means anything. A store where clients set their own ordering, their own timestamps and their own hashes would hold the same data and prove nothing about it. The four fields you fill in are exactly the ones where you are the authority – what happened, to what, from where, with which details. The rest is the store's testimony, and testimony you write yourself is worth what it cost you.
If you want to see the transformation for yourself, write one event and read it back: Writing Events covers the first half, Reading Events the second, and all eleven fields are there at the end. And if you have ever wanted that top-level metadata field, we would like to hear what you were trying to put there: hello@thenativeweb.io. What people reach for says more about a format than any specification does.