Schema evolution
Every event carries a stable schemaHash; the ingest never blocks on a version mismatch, and a committed schema history makes your repo the schema registry.
Your catalog will change. Your clients will not change with it — at least not all at once: a SPA left open for days, an app not yet reloaded, will keep emitting yesterday's shape after today's deploy. Naive strict validation would turn every schema evolution into a data-loss window. Dotyc's stance: data is immutable, meaning is versioned — and version mismatch never blocks.
Every event carries its schema version
A stable hash of the event's schema — computed on the normalized definition at build time — is embedded in every emitted event:
{ name: 'feature_used', schemaHash: 'a3f2…', properties: { ... }, ... }The full definition is deliberately not embedded in the envelope: it would add permanent, duplicated weight to every request. The hash is enough, because of what comes next.
The repo is the schema registry
A generated, committed, append-only file — .dotyc/schema-history.json — maintained by the build/CLI, maps every hash to the full schema definition at that version. Git already carries your code's history; now it carries your schemas' history too.
This is what enables reinterpreting legacy events later: the ingest (or future tooling) can re-validate or re-read any event against the exact schema of its era, without the event ever having transported it.
Behavior at ingest
| Case | Behavior |
|---|---|
| hash = current version | Strict validation, unchanged: invalid ⇒ rejected, onError |
| hash known in the history | Never blocked: validated against its own schema, dispatched flagged legacy |
| hash unknown | Never blocked either: dispatched flagged unknown-schema, raw properties |
Durable legacy events are persisted like any other — a business fact is never lost because a client is one version behind. Consumers see the flag and decide:
async handle(events) {
for (const event of events) {
if (event.schema.status !== 'current') {
// legacy or unknown-schema — route to observation, tolerate, or skip
}
}
}Every ValidatedEvent carries schema: { hash, status } with status: 'current' | 'legacy' | 'unknown'. The ingest response acks legacy events as legacy rather than accepted, so the emitter side sees them too.
Why never block
The alternative designs all lose data or lie about it:
- Reject old shapes → every deploy silently drops events from not-yet-reloaded clients. Worst for exactly the events you care most about (durable business facts from long-lived sessions).
- Validate loosely → consumers can no longer trust their input; the strict-by-design promise dies.
- Version fields manually → tracking debt with extra steps.
Hash-flagging keeps both promises at once: events validated against the current schema are strict as ever, and events from another era arrive intact, honestly labeled, validated against the schema they were written for.
Draft: still being decided
legacy flag is matchable in the wiring (routing legacy events to an observation adapter); the default policy for unknown-schema on durable events (persist + flag, or quarantine); and the CLI command that updates schema-history.json plus its CI check (missing hash = build fail).Delivery guarantees
Two delivery classes declared per event — best-effort for the noise, durable at-least-once for business facts — plus a trusted source rule for anything with monetary consequence.
Identity
Lightweight client ids reconciled to a verified userId as early as possible — with identity providers that plug into the auth you already have.