AI agents

The Invalidation Event Contract: Fields a Publisher Must Send

Antonio Evans11 min read

A single envelope labeled 'Invalidation Event' with fields listed on it (event_id, content_id, version, scope, timestamp, reason, origin).
Key points

Key points

  1. 1A change event needs seven fields: event id, content id, version, scope, timestamp, reason and origin, and each consumer reads a different subset of them.
  2. 2The gate purges by tag, the in-process cache (L1) purges by key, and the shared cache (L2) purges by key plus a version check.
  3. 3A shared contract test suite, run by publishers and consumers, catches missing fields, wrong types and non-monotonic versions before they reach production.
  4. 4When events go missing, a fallback TTL plus a reconciliation job that compares stored versions keeps staleness bounded instead of unbounded.

Why one event has to serve three consumers

Cache invalidation fails in the seams between layers. The classic case is a database write that bypasses the application, so the ORM has no way of knowing a cached entity went stale and the cache keeps serving old data until someone clears it by hand the Debezium cache invalidation writeup. Change data capture fixes that by reading the database's own log rather than querying tables, so every insert, update and delete becomes an event with a before state, an after state and metadata the Debezium CDC explainer.

Once that stream exists, three consumers want it at once. Your edge gate holds rendered responses keyed by tags. Your L1 in-process cache holds objects keyed by content id. Your L2 shared cache holds the same objects but across many processes. If each layer listens to a different signal, you get the failure mode where L1 clears and L2 does not, or the gate keeps serving a page for a tag that was already purged downstream.

The fix is a single contract: one event shape, published once per change, with fields that let each consumer act without asking questions. That is the whole design. The rest of this article is the field list, the semantics, and the failure handling.

A comparison shows why the split matters:

LayerWhat it storesPurge triggerNeeds version?Needs tag?
Edge gateRendered responsesTag scope eventsNoYes
L1 in-processObjects per processKey scope eventsNo (process evicts on write)No
L2 sharedObjects across processesKey plus version checkYesNo
Reconciliation jobVersion indexCompares stored vs. sourceYesOptional

If you collapse the contract to one generic "invalidate" message with only an id, the gate cannot purge by tag without a lookup, and L2 cannot reject a stale event that arrives after a newer one. Two extra fields, scope and version, carry almost all of the coordination value.

The seven required fields

Every event carries exactly these fields. Extra fields are allowed but never load-bearing.

{
  "event_id": "0f3c6b1e-5a2d-4f0a-9b7e-2f1c8d4a6e90",
  "content_id": "product:4417",
  "version": 1842,
  "scope": { "kind": "key", "tags": ["catalog:shoes", "listing:page-3"] },
  "occurred_at": "2025-11-04T09:12:33.412Z",
  "reason": "price_update",
  "origin": "catalog-writer"
}

Here is what each field means and who reads it.

event_id: a UUID produced once at publish time. It is the deduplication key. Consumers keep a short-lived set of seen ids, and it doubles as the correlation id in logs so you can trace one change through gate, L1 and L2. A request that arrives twice with the same event_id must be a no-op the second time, which is the same idempotency discipline webhook receivers need to avoid replay attacks the webhook security guide.

content_id: a namespaced identifier for the thing that changed, for example product:4417 or article:slug/foo. L1 and L2 build their cache keys from this, so it must match the key function the read path uses. If it does not, the event purges nothing and the cache looks fine while serving stale data.

version: a monotonically increasing integer per content_id, bumped on every write. This is the ordering mechanism. L2 keeps the version it cached and ignores any event whose version is lower than what it already stored. That single rule fixes the out-of-order problem where a slow bypass write lands after a newer application write. Data consumers whose correctness depends on freshness need exactly this kind of ordering guarantee, and it is the same reasoning SRE teams apply when they tie reliability targets to measurable indicators the SRE workbook chapter on SLOs.

scope: an object with a kind and an optional tag list. Kind is key, tag, or all. A key event purges only the named content_id. A tag event purges every entry carrying a listed tag, which is how the edge gate turns one change into a purge of a listing page. An all event is a full flush and should be rare and audited.

occurred_at: an ISO 8601 timestamp with milliseconds and an offset, set when the change was committed, not when the event was published. Consumers use it for observability and to compute age, never for ordering. Ordering belongs to version.

reason: a short code such as price_update, delete, publish, schema_change. Consumers do not branch on it today, but it is the field that makes incident review possible, and it is the first place you look when a purge storm appears in metrics.

origin: the service that made the change. This lets you attribute events during a rollout, mute a noisy publisher, and detect duplicate publishers when two services both emit for the same content_id.

What each consumer does with the event

The same event drives three different actions, and being explicit about each is what stops drift.

The edge gate cares about scope. On a tag event it purges every cached response carrying any listed tag. On a key event it purges only responses tagged with that content_id. On all it flushes the whole node group. It never needs version, because it holds no per-entry ordering state. That is a deliberate simplification: keeping version state at the edge costs memory and buys nothing when the rebuild path always fetches fresh.

L1 cares about content_id only. Each process holds a map from content_id to value, and an event for that id deletes the entry. Because L1 is per-process and short-lived, the loss of an event costs seconds of staleness, not minutes. That bounded blast radius is also why L1 can be the least careful consumer.

L2 cares about content_id and version. On receiving an event it compares the incoming version to the version stored alongside the cached value. If incoming is greater, delete. If equal or lower, ignore and count it as a stale event. If no entry exists, record the version anyway if your design tracks a version index, so a later repopulation does not overwrite newer data.

Tag-based invalidation has a known cost: a tag drawn too broadly discards far more cached data than the change required and triggers an avoidable burst of misses the Redisson invalidation glossary. Version-based invalidation has the opposite trade-off, since the old entry is orphaned rather than deleted and needs TTL and eviction to reclaim memory the Redisson invalidation glossary. The contract above lets you use tags at the edge, where the trade-off is cheap, and versions in L2, where correctness matters more.

Worked example: one price change, three purges

Take a product whose price changes from 20.00 to 18.00. The writer commits the row and bumps version from 1841 to 1842. It publishes one event with content_id: product:4417, version: 1842, scope.kind: tag, tags catalog:shoes and listing:page-3, reason: price_update.

The gate receives it and purges two response groups: the product page group tagged with the item and the listing page group. Suppose that listing page held 48 cached responses, one per country and device variant. All 48 are gone and will be rebuilt on next request. At a rebuild cost of 40 ms each, that is roughly 1.9 seconds of extra work spread across the next minute of traffic, not a spike if requests are spread.

L1 receives the same event and deletes the single object under product:4417 in each process that saw it. No version bookkeeping.

L2 receives it and finds a cached value at version 1841. Since 1842 is greater, it deletes the entry and stores 1842 in its version index.

Now the failure case. A nightly batch job writes the same row directly to the database, bypassing the writer, so no event is published. L2 keeps serving 18.00 while the database says 17.50. Nothing catches it until either the TTL expires or the reconciliation job runs. That is why the fallback section below is not optional.

A worked comparison of what each consumer would do if you omitted a field:

Omitted fieldGate effectL1 effectL2 effect
scopeCannot purge by tag, falls back to full flushUnaffectedUnaffected
versionUnaffectedUnaffectedCannot reject out-of-order events
event_idDuplicate purges, harmless but noisyDuplicate deletesDuplicate deletes and version writes
occurred_atNoneNoneNone, until you need to measure age

The scope and version omissions are the two that cause real incidents. The event_id omission causes load. The occurred_at omission only hurts observability, which is why some teams make it optional, though I would keep it required to force publishers to think about clock source.

The contract test suite

A contract is only real if it is executable. Publish a shared test package that both sides run, with a fixtures file of events and expected outcomes. The suite validates three things.

Field presence and type. Every required field exists, version is an integer, occurred_at parses as ISO 8601 with an offset, scope.kind is one of the three allowed values, and tags are non-empty strings when present. Run this against the publisher's serializer as part of its build, not after deploy.

Version monotonicity. Feed the suite a sequence of events for one content_id and assert that versions strictly increase. Some teams allow equal versions for idempotent republishes, so decide which rule you want and test it.

Consumer behavior. For each layer, assert the mapping. A tag event purges all matching groups. A key event purges only the named key. A stale version is ignored at L2. A duplicate event_id is a no-op everywhere.

The same discipline applies to security: verify the signature or shared secret on every incoming event and reject anything that fails, because a forged invalidation event is a cheap denial-of-service against your cache the webhook security guide. Treat the event endpoint as untrusted input even when it sits inside your network.

A checklist for the test suite itself:

  • Fixtures are checked into the shared package and versioned alongside the schema.
  • Both the publisher and all three consumers run the suite in CI.
  • Adding a required field means bumping the schema version and updating fixtures first.
  • The suite includes at least one deliberately malformed event per field, so a lenient parser fails loudly.
  • The suite runs against a real L2 instance, not a mock, because the version index is where bugs hide.

Data quality teams use the same framing when they measure how often records arrive wrong: define the indicator, set the objective, and let the error budget tell you when to stop shipping features and start fixing pipelines Muness Castle on data quality SLOs. Your event stream deserves the same treatment. Track publish rate, consumer apply rate, stale-event rejections and gate purge count as four separate indicators, and alert when the gap between publish and apply grows.

When events go missing: TTL and reconciliation

Every event-driven design needs a fallback for silence, because publishers crash, topics drop messages and batch jobs bypass writers. The standard answer is a hybrid of TTL and events, which costs more complexity but bounds staleness the OneUptime invalidation guide.

Fallback TTL. Give every cache entry a TTL even when events work perfectly. Set it per data type: short for prices and inventory, long for descriptions. With a 60-second TTL you accept up to 60 seconds of staleness, which is fine for a product listing and not fine for an account balance the Redisson invalidation glossary. The TTL is the ceiling on your worst case, and writing it down as a number is the decision that matters.

Reconciliation. Run a periodic job that walks stored versions in L2 and compares them to the source of truth. Where the source version is higher than the cached version, purge the key and emit a synthetic event with reason reconcile so the gate and L1 also clear. Where an entry has no source record, purge it as a delete. Measure what fraction of entries the job had to fix. A steady nonzero rate means a publisher is silently failing, and that number is your most useful alert.

Choosing the reconciliation interval is a judgment call. A useful rule: set it to a small multiple of the fallback TTL, so the TTL handles most silence and the job catches the entries that never expire because they are read constantly. Run the job off-peak and cap its purge rate so it cannot itself cause a miss storm.

Data freshness is worth measuring explicitly rather than assuming: define what fresh means for each dataset and track the fraction of reads that fell inside the window Decube on data freshness. The same idea applies here. If your SLO says 99 percent of reads see a version no more than five minutes old, then TTL plus reconciliation is how you defend it, and the event stream is how you usually meet it without paying the cost.

Publishing safely and rolling the contract out

Three rules keep the pipeline honest.

First, publish after commit, never before. If the event goes out and the transaction then rolls back, every consumer purges a value that never changed, and the read path refills the old value at the same version, which L2 will then reject as stale. If you need atomicity between the write and the publish, use a transactional outbox so the event is written in the same transaction and relayed afterward.

Second, make publishing a single function that every write path calls. When a service writes to its database, it should behave as the owner of that data and emit the event from one place, because a write path that forgets creates a stale entry nothing clears until TTL the Redisson invalidation glossary. Code review for this is cheaper than any monitoring.

Third, roll out schema changes additively. Add fields as optional, let every consumer ship support, then make them required and bump the schema version. Removing a field follows the reverse order. A contract test suite makes each step verifiable.

Teams building this pipeline also tend to be running models behind it, embeddings for search or a ranking model for listings. If the change events already move through one system, it is worth routing model calls through the same discipline. Quanta Inference gives you text, image, video, audio and embedding models through one OpenAI-compatible API key, priced in US dollars, so the rebuild path after a purge calls one endpoint instead of five vendor SDKs (https://quantaintelligence.ai/inference).

The test of a good invalidation contract is boring: when a writer breaks, the reconciliation job reports it before a customer does, and when you add a new cache layer, it takes a day rather than a quarter because the event already carries what it needs.

FAQ

FAQ

Do I need both a version and a timestamp?

Yes, and they do different jobs. The timestamp records when the change happened so you can measure age and debug incidents. The version is the only field allowed to decide ordering, because clocks across writers drift and a later write can carry an earlier timestamp.

Should the edge gate track versions?

No. The gate holds rendered responses keyed by tags and rebuilds from scratch on a miss, so per-entry version state costs memory without improving correctness. Version tracking belongs in L2, where entries persist and out-of-order events are the real risk.

What should happen when a consumer receives an event for a key it never cached?

Delete nothing and, in L2, record the version. Writing the version prevents a slow reader that fetched the old value from repopulating the cache at a version below the one already known, which is the stale-repopulation race that version keying avoids the Redisson invalidation glossary.

How small should the fallback TTL be?

It depends on what the data is worth being wrong about. Set the TTL to the longest staleness you would tolerate if every event were lost, then set the reconciliation interval to a small multiple of it. Measure the fraction of entries the job fixes and treat a rising rate as a publisher bug, not a tuning problem.

Is a full flush ever acceptable?

Only as a deliberate, audited action, for schema changes or a bad deploy that corrupted values. A publisher that emits all routinely will destroy hit ratios and make your latency metrics meaningless, so require an explicit reason code and alert on every occurrence.

Sources

Sources

  1. Automating Cache Invalidation With Change Data Capture
  2. Understanding Change Data Capture with Debezium
  3. Webhook Security Vulnerabilities Guide
  4. Chapter 2 - Implementing SLOs
  5. Cache Invalidation: Strategies and Redis Examples | Redisson
  6. Implementing SLOs for Data Quality | Muness Castle
  7. How to Create Event-Based Invalidation
  8. Data Freshness: What It Is, How to Measure It, and When ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles