The event was a command wearing past tense

P6X4's event map looked decoupled until I distinguished facts that had happened from requests that still needed an owner and a refusal path.

DocumentNeedsRendering looked like an event. It was published to a topic, used a past-adjacent name, and had several asynchronous consumers.

It was still a command.

The document process emitted it because it wanted exactly one renderer to perform work. Rendering could be refused if the template was unavailable. It had a deadline, an idempotency identity, a result, and an owner expected to act. Calling the message an event removed none of those obligations. It only made them harder to see.

P6X4's first architecture contained many messages like it. The system had adopted the visual language of event-driven design while smuggling imperative requests through a broadcast mechanism.

Past tense was not a semantic guarantee

I had used a naming rule: events should be in past tense. It caught obvious commands such as GenerateDocument, which became DocumentGenerationRequested. The revised name was grammatical and still described a request.

The stronger distinction was about truth and authority.

A fact records something the publishing authority says has happened:

{
  "type": "DocumentRevisionCommitted",
  "documentId": "doc_42",
  "revision": 7,
  "templateRevision": 3,
  "occurredAt": "2022-04-12T14:08:00Z"
}

A command asks a named capability to decide or act:

{
  "type": "RenderDocument",
  "commandId": "cmd_9f2",
  "documentId": "doc_42",
  "revision": 7,
  "templateRevision": 3,
  "requestedAt": "2022-04-12T14:08:00Z",
  "deadline": "2022-04-12T14:13:00Z"
}

The command had not happened merely because it was placed on a broker. A renderer could reject it, discover it was obsolete, time out, or complete with a receipt. Those outcomes needed their own facts.

Renaming did not matter as much as modelling that protocol.

Broadcast hid singular responsibility

P6X4 initially published DocumentNeedsRendering to a topic. One renderer consumed it. A thumbnail experiment later subscribed as well, followed by a notification consumer that wanted to announce readiness.

The message accumulated fields to satisfy each listener. The renderer wanted an immutable document and template revision. The thumbnail worker wanted asset locations. Notification wanted the requester and display title. Every consumer behaved as though the message had been designed for it.

The publisher could not answer a basic question: which listener was required for the original request to succeed?

I split the flow.

The document transaction recorded a RenderDocument command in an outbox addressed to the rendering capability. When the renderer accepted and completed it, the rendering authority published DocumentRendered with the artifact identity and source revisions. Thumbnail and notification projections could react to that fact independently.

One request had one accountable handler. A durable fact could have zero, one, or many interested consumers.

The broker topology now followed semantics instead of supplying them.

Facts required an authority

Another suspicious message was DeliveryCompleted, published by both the delivery worker and a reconciliation job.

The worker emitted it after a successful adapter response. The reconciler emitted the same type after querying an external system following a timeout. Downstream consumers accepted whichever arrived first. That looked resilient until the two sources disagreed about the target identifier.

I made the delivery ledger the authority. Adapter attempts and status queries produced evidence. The ledger applied evidence under a policy and recorded the transition. Only that transition published DeliveryCompleted.

The event therefore meant more than “some process observed something resembling completion.” It meant “the delivery authority committed completion for this stable effect identity based on this evidence.”

This definition gave consumers a fact they could safely project. It also preserved the evidence reference for audit and correction.

An event without a named authority was often just a rumour distributed quickly.

Commands needed refusal paths

The event-shaped command encouraged handlers to assume that processing must eventually succeed. Failures went to retries and then a dead-letter queue.

Real commands can be invalid, unauthorized, obsolete, impossible before a deadline, or temporarily unavailable. Those cases are not all transport failures.

For RenderDocument, I defined outcomes:

  • accepted: the command identity was recorded and would reach a terminal state.
  • rejected: the request violated a stable policy or referred to incompatible revisions.
  • superseded: a later document revision made the requested artifact unnecessary.
  • completed: the exact source and template revisions produced an immutable artifact.
  • failed: execution ended without an artifact and policy allowed a new command.
  • outcome-unknown: an external renderer might have accepted the request, but no reliable status was available.

Most local rendering failures were knowable. The unknown state mattered for adapters outside P6X4's authority.

The caller needed to understand which outcomes closed its intent and which required intervention. A generic “event processing failed” alert could not provide that contract.

Facts were immutable, interpretations were not

Calling something a fact tempted me to treat its schema and meaning as eternally correct.

The event record was immutable in the sense that history was not silently rewritten. Interpretation still evolved. A DocumentRendered fact from version one did not include a content digest, so later code could not prove artifact equivalence using that field.

I kept the original event, documented its limits, and wrote an upcaster only where a missing value could be derived without invention. Otherwise, consumers handled both versions explicitly or rebuilt from authoritative source records.

Corrections were new facts. If an artifact was later found invalid, RenderedArtifactInvalidated referenced the original artifact and supplied a reason. It did not mutate the earlier claim that the renderer had completed under its then-current checks.

Immutability preserved what the system knew and decided at the time. It did not turn incomplete history into perfect truth.

Not every state change deserved an event

Once the message infrastructure existed, publishing felt cheap. I briefly emitted events for every table update: DocumentTitleUpdated, DocumentSubtitleUpdated, DocumentLocaleUpdated, DocumentTemplateUpdated.

Consumers assembled them into their own document copy. A partial replay or missed version produced combinations that had never existed authoritatively.

I replaced the field-level stream with DocumentRevisionCommitted. One revision contained the coherent snapshot required by projections. Fine-grained audit details remained inside the document module where their ordering and validation rules were known.

The event boundary exposed a stable external fact, not every internal mutation.

This reduced flexibility for consumers that wanted to react to one field without loading the revision. The trade was deliberate. A consumer could compare revisions or subscribe to a narrower, explicitly supported fact later. It could not infer a valid document from arbitrary field deltas.

Event time had more than one clock

The first event schema contained one timestamp. It meant different things in different publishers.

I separated:

  • occurredAt: when the authoritative transition happened.
  • recordedAt: when the local event record was committed.
  • publishedAt: when a relay sent it to the broker.
  • receivedAt: when a consumer accepted the delivery.

For local transactional events, occurred and recorded time were usually close. They were still distinct from publication and reception. Retries could produce a much later receivedAt without changing when the fact occurred.

Ordering used authority-specific sequence or revision numbers where it mattered, not wall-clock time across machines. A search projector ignored an older document revision even if its event arrived later. Delivery evidence used attempt identities and ledger rules rather than choosing the message with the newest timestamp.

The extra fields were not ceremony. They prevented transport delay from rewriting business order.

The broker could redeliver messages. Pretending each event was observed exactly once moved duplication into downstream side effects.

Every event had a stable identifier, but consumer deduplication was scoped carefully. A projection stored the last applied source revision per entity. A notification consumer stored a receipt for the specific notification effect. A metric counter could sometimes tolerate duplicate observation or use its own aggregation identity.

“We have seen event ID X” was not always sufficient. If a consumer transaction updated its database and then crashed before recording the seen ID, it could apply the effect twice. The projection update and consumption receipt needed one local transaction, or the operation needed to be naturally idempotent.

For external notification adapters, Z29C's effect protocol applied: stable identity, adapter-specific idempotency where available, receipts, and an unknown-outcome path.

Event-driven architecture did not abolish side-effect semantics. It multiplied the places where they mattered.

Schema ownership followed meaning

Initially, a central events package defined every message. It became a negotiation point and quietly owned concepts from every capability.

I moved source fact schemas beside the authority that published them. The document module owned DocumentRevisionCommitted. The rendering module owned DocumentRendered. Consumers depended on published contracts, not internal types.

Commands belonged to the capability asked to handle them. The renderer defined what a valid RenderDocument request required. A caller could construct that contract through a public client or package, but could not add convenient fields unilaterally.

Shared envelope fields—message ID, contract name and version, correlation, causation, timing, and trace context—remained in a small transport package. The envelope did not own payload meaning.

This arrangement made compatibility responsibility clearer. Publishers maintained facts already released. Command handlers maintained the accepted request versions for their capability.

Correlation was not causation

P6X4 attached one workflow identifier to every message in a document request. It helped retrieve the trace and encouraged the interface to draw a simple chain.

The actual graph branched.

A DocumentRevisionCommitted fact caused search projection and made a render command eligible. The render completion caused a thumbnail projection and satisfied one condition for delivery. A manual retry correlated with the same user intent but was caused by a recovery decision, not the original event alone.

I added separate fields:

  • correlationId grouped messages within the broader intent.
  • causationId referenced the specific command or fact that directly prompted this message.
  • messageId identified this immutable record.
  • effectId identified a consequential external operation across attempts.

The distinction paid off during recovery. I could tell whether a duplicated notification came from broker redelivery, a second command, or a deliberate compensation workflow.

One trace ID could locate the story. It could not explain it.

I reviewed messages with four questions

For every topic and queue in P6X4, I wrote:

  1. Is this a fact, a command, evidence, or a transport signal?
  2. Who has authority to publish or decide it?
  3. What outcome or acknowledgement does the sender need?
  4. What may a consumer safely conclude from it?

Transport signals included wake-ups such as “projection work is available.” They were allowed to be lossy only when a durable store remained the source of work. Evidence included adapter responses that a ledger still had to interpret.

The categories prevented every message from acquiring the prestige of a domain event.

Several topics disappeared. An in-process function call better expressed a synchronous command within the consolidated application. Several others became outbox wake-ups rather than payload authorities. The durable facts that remained became narrower and more meaningful.

The revised P6X4 flow was less symmetrical.

One transaction committed a document revision and its authoritative fact. A local policy created a render command through the outbox. The render worker could run as a separate process because of its resource profile while sharing the same versioned application artifact. Its terminal ledger transition published a render fact. Search and notifications projected facts according to their own recovery policies. Delivery retained a dedicated external-effect protocol.

Some arrows were commands. Some were facts. Some were local calls. Some were wake-ups pointing to durable work. Their visual inconsistency reflected meaningful differences.

The original diagram had used identical event arrows everywhere. It looked decoupled because responsibility was absent from the picture.

Grammar could not substitute for a protocol

I kept past tense for facts because it makes prose and timelines clearer. I stopped using it as a design test.

DocumentGenerationRequested is grammatically an event about a request. If the publisher expects a renderer to act, it participates in a command protocol. DocumentRendered is a fact only when a recognized authority has committed what the term means. RenderTimedOut may describe a worker's observation without proving whether an external effect occurred.

The semantics live in authority, expected response, refusal, identity, and what downstream code may conclude.

P6X4's message system became easier to operate after fewer messages were called events. Commands gained owners and outcomes. Facts gained authorities and revision rules. Evidence stopped masquerading as final state. Transport stopped pretending to be the domain.

The event had been a command wearing past tense. Taking off the costume did not make the system less event-driven. It made every remaining event worth believing.

The dead-letter queue was not a business outcome

Before the review, an exhausted message moved to a dead-letter queue and produced an alert. I had treated that as a robust end state because the payload was not lost.

The queue preserved transport evidence. It did not tell the document workflow whether rendering had been refused, whether delivery might have happened, or whether a request was now obsolete. Operators—meaning me, in the personal lab—had to read a payload, inspect logs, infer the intended handler, and decide whether replaying it was safe.

I changed dead-letter handling into ingestion for a recovery record. The record linked the stable command or event identity, consumer contract version, attempts, last classified failure, and the authoritative workflow state. Its available actions came from policy:

  • Requeue a command only when another attempt was permitted.
  • Rebuild a projection from source facts rather than replaying one arbitrary message.
  • Mark a transport delivery superseded when the authoritative revision had advanced.
  • Query an external adapter before retrying an effect with an ambiguous outcome.
  • Escalate an incompatible schema instead of sending it through the same consumer again.

The raw broker payload remained available as evidence. The recovery interface began with meaning, not a Retry button.

This work further separated commands from facts. Reprocessing a fact should make a projection converge. Reissuing a command might create work and required an explicit decision.

Contract tests encoded what could be concluded

Schema validation proved that a message had the expected fields. It did not prove that consumers interpreted those fields safely.

For each published fact, I wrote contract fixtures and a short conclusion table. From DocumentRevisionCommitted, a consumer could conclude that the named authority had committed that exact revision and template reference. It could not conclude that rendering, indexing, notification, or delivery had finished.

Consumer tests used sparse fixtures and unknown fields to preserve forward compatibility. They also exercised duplicates, late arrival, skipped revisions, and a newer revision arriving first. A search projector converged on the highest complete source revision; it did not apply events in broker order blindly.

Command contracts tested different obligations. A handler had to deduplicate the stable command identity, validate supported versions, record acceptance or rejection, and make a terminal outcome discoverable. A timeout at the transport layer could not be converted directly into failed without knowing whether the handler had accepted the work.

These tests were more verbose than sharing one TypeScript type across every package. They protected semantics that a structural type could not express.

An immutable log made historical replay possible and made indiscriminate replay dangerous.

Projection consumers were designed to rebuild into a new store from a bounded source sequence. During a rebuild, no external notifications or commands were emitted. The projector operated under a rebuild mode that prohibited consequential ports and reported unsupported historical contracts.

Workflow consumers were not replayed as though time had returned to the original moment. A 2021 delivery command could refer to expired credentials, superseded content, or a person who no longer had authority. Recovery loaded the current workflow and applied an explicit policy to historical evidence.

I recorded retention requirements per fact stream. Audit facts and authoritative revisions had durable value. High-volume diagnostic signals did not become permanent domain history merely because the broker could retain them.

The ability to replay was therefore a designed property of a consumer and its source, not a blanket promise attached to the word “event.”

Deleting messages completed the redesign

The semantic inventory removed more infrastructure than it added.

Field-level document events disappeared. Internal synchronous requests became package calls. Several topic payloads became lightweight wake-ups pointing to outbox rows. A duplicated delivery-complete publisher was removed after the ledger became authoritative. Broker routes, consumer credentials, retry policies, dashboards, and dead-letter alarms associated with those messages were deleted as part of the same change.

I added a topology snapshot test that listed queues, topics, publishers, consumers, and contract owners. A removed flow failed the test until its operational residue was gone. That prevented a silent consumer from retaining credentials and waking on messages no one intended to send.

The smaller map was easier to explain because each remaining asynchronous edge represented delay or recovery the product actually accepted.

Event-driven design had originally encouraged me to distribute possibility. The review taught me to distribute facts only when another part of the system had a legitimate reason to know them, and to send commands only when the responsibility for answering was explicit.