Events and Commands as Message Types: ADR-0006
-
Status: Accepted
-
Date: 2026-02-28
-
Authors: Claude, Vadim Kuhay
Summary
All communication between bounded contexts uses typed messages implemented as Kotlin sealed hierarchies. Commands change state (one producer, one consumer). Queries request information (one producer, one consumer, must be answered). Events carry facts (one producer, many consumers). Notifications reach the connected mind. No context calls another’s methods directly. No shared mutable state.
Governing Dynamic
If you can’t name it, you can’t reason about it. Every interaction between contexts is a named message with a typed payload.
Motivation
Six bounded contexts need to coordinate. The naive approach: contexts call each other’s methods directly.
Cortex calls tieredMemory.store(), Salience calls tieredMemory.changeTier(), Subconscious calls salience.decaySweep().
This works until it doesn’t. Direct method calls create compile-time coupling — every context that calls another needs its interface on the classpath. Call chains become hidden dependencies. Testing requires mocking entire context implementations. And the interactions between contexts are invisible — buried in method signatures instead of named and documented.
Messages make interactions explicit. A StoreCommand is a thing you can see, name, log, test, and reason about.
A method call store(content, tier, metadata) is the same operation hidden inside a call stack.
When something goes wrong, the message is traceable. The method call requires a debugger.
Kotlin sealed hierarchies make this type-safe at compile time.
The compiler enforces exhaustive handling — if a new message variant is added, every when expression that matches on the hierarchy must handle it or fail to compile.
No message can be silently ignored.
Guide-Level Explanation
Four kinds of messages flow through Total Recall:
Commands are requests that change state. "Store this memory." "Run a decay sweep." "Begin shutdown." A command has one sender and one receiver. It carries intent — something should happen. The receiver may accept, reject, or ignore it.
Queries are requests that read state. "Search for memories matching this filter." "Reflect on this topic." A query has one sender and one receiver. It carries a question — information is needed. Queries must be answered. They do not change state.
Events are facts. "A memory was stored." "A tier changed." "An salience score was calculated." An event has one producer and any number of consumers. It carries what happened — not what should happen. Events are never rejected because they describe the past.
Notifications reach the connected mind. "You’ve been in task mode for 90 minutes." "Session ending — what do you refuse to lose?" Notifications flow outward through the Notification Port. The mind can act on them or ignore them.
The separation matters. Commands and Queries flow inward (toward the domain) — Commands change state, Queries read it. This is CQRS at the message level. Events flow between contexts. Notifications flow outward (toward the mind). Each direction has different semantics and different failure modes.
All four are implemented as Kotlin sealed interface hierarchies. Each variant is a data class with a typed payload.
The compiler guarantees exhaustive matching.
Reference-Level Explanation
Sealed Hierarchy Structure
sealed interface Command
data class StoreCommand(...) : Command
data class ClaimCommand(...) : Command
// ... 7 total variants
sealed interface Query
data class SearchQuery(...) : Query
data class ReflectQuery(...) : Query
sealed interface Event
data class MemoryStored(...) : Event
data class MemoryAccessed(...) : Event
// ... 16 total variants
sealed interface Notification
data class BreakNotification(...) : Notification
data class SessionAuditPrompt(...) : Notification
data class TotalRecallNotification(...) : Notification
// ... 3 total variants
Package: mimis.gildi.memory.domain.message.
Every variant carries a TransactionContext as its first field — a six-field chain of custody envelope (sessionId, requestId, messageId, causationId, timestamp, sourceContext). See Design F: TransactionContext.
Why Sealed, Not Open
Sealed hierarchies are closed — no external code can add new variants. This is deliberate:
-
The compiler enforces exhaustive
whenexpressions. Adding a new variant to any hierarchy forces every handler to be updated. -
The message catalog is the system’s contract. If anyone can add message types, the contract is undefined.
-
Runtime surprises are eliminated. A handler for
Commandknows it will only ever see the defined variants.
An open hierarchy (interface or abstract class) would allow external modules to add message types. This is appropriate for plugin systems. It is wrong for a domain where every message is a named, documented contract.
Command Semantics
-
One producer, one consumer.
-
Carries intent: "do this."
-
May be accepted, rejected, or ignored.
-
Produces events on success.
Example flow: StoreCommand (Cortex → Hippocampus) produces MemoryStored (consumed by Salience and Synapse).
Query Semantics
-
One producer, one consumer.
-
Carries a question: "tell me this."
-
Must be answered — a query without a response is a bug.
-
Does not change state.
Example flow: SearchQuery (Cortex → Recall) returns matching memories ranked by salience. ReflectQuery (Cortex → Recall) returns candidates for retrospection.
Event Semantics
-
One producer, many consumers.
-
Carries fact: "this happened."
-
Never rejected — it describes the past.
-
May trigger further commands in consumers.
Example flow: MemoryStored (produced by Hippocampus) triggers initial score calculation in Salience and initial association creation in Synapse.
Notification Semantics
-
One producer (Subconscious), one delivery channel (Notification Port).
-
Carries a prompt or alert for the connected mind.
-
The mind may act on it or ignore it.
-
Does not produce further domain events.
Why Notifications Are a Separate Hierarchy
Commands, Queries, and Events are established CQRS/DDD patterns with extensive literature. Notifications are unique to Total Recall — the mind-facing outbound message. This section explains why they do not extend any of the other three.
Notifications vs Events:
-
Shared: Both carry facts. Both cannot be rejected. Both are system-initiated — nobody asks for them.
-
Events add: Fan-out to many internal consumers. May trigger further Commands. Describe the past only.
-
Notifications add: Single outbound delivery channel (Notification Port). Can carry prompts that look forward — "Session ending — what do you refuse to lose?" is prescriptive, not descriptive.
-
Notifications remove: No domain side-effects. No internal fan-out.
Notifications vs Commands:
-
Shared: Both target a single receiver. Both can be ignored.
-
Commands add: Flow inward. Change domain state. Produce Events on success. Get a formal response (accepted, rejected, ignored).
-
Notifications add: Flow outward. Cross the hexagonal boundary toward the mind.
-
Notifications remove: No state change. No Events produced. No formal response — the domain never learns whether the mind acted.
Notifications vs Queries:
-
Shared: Both have a single target. Both carry information to a recipient.
-
Queries add: Flow inward. Must be answered — an unanswered Query is a bug. Initiated by a request.
-
Notifications add: Flow outward. Unsolicited — the system decides when to inform, not the recipient.
-
Notifications remove: No response expected. No request triggers them.
The hybrid nature is telling: Notifications carry information like Query responses but are initiated by the system like Events. They inform without being asked. No existing hierarchy accommodates both traits.
Notifications are the only message type that crosses the hexagonal boundary outward. This directional uniqueness alone justifies a separate hierarchy — routing logic can distinguish inbound messages (Commands, Queries) from internal messages (Events) from outbound messages (Notifications) at the type level.
Message Catalog Counts
-
Commands: 7 variants (StoreCommand, ClaimCommand, AssociateCommand, ReclassifyCommand, ConsolidateCommand, ShutdownCommand, DecaySweep)
-
Queries: 2 variants (SearchQuery, ReflectQuery)
-
Events: 16 variants (MemoryStored, MemoryAccessed, MemoryClaimed, MemoryRetrieved, TierChanged, TierPromoted, TierDemoted, SalienceScored, AssociationsFound, MemoryReclassified, TotalRecallAdvisory, SessionStart, SessionEnd, StateTransition, ModeChanged, SessionState)
-
Notifications: 3 variants (BreakNotification, SessionAuditPrompt, TotalRecallNotification)
Full payload details are documented on the Message Catalog architecture page.
Prior Art
Akka / Actor Model (Hewitt, 1973)
Actors communicate exclusively through messages. No shared state. Each actor processes messages sequentially. This is the theoretical foundation. Total Recall’s bounded contexts are actors, and their messages are the commands, queries, events, and notifications defined here. Vadim’s prior work with Akka-Kotlin Actor model architectures (call center bots, 2022) directly informs this choice.
Domain-Driven Design (Evans, 2003)
Domain events as a pattern for inter-aggregate communication. "Something happened that domain experts care about." Evans' domain events map directly to our Event hierarchy. Our Commands and Queries extend the pattern to cover the request side — writes and reads respectively. Notifications extend it outward to the connected mind.
CQRS (Young, 2010)
Command Query Responsibility Segregation formalizes the separation of commands (writes) and queries (reads). Total Recall follows this directly: Commands and Queries are separate sealed hierarchies. Commands change state and produce Events. Queries read state and return results. They never mix — a message that reads and writes would violate the segregation that makes the system predictable.
Kotlin Sealed Classes
Kotlin’s sealed hierarchies were designed for exactly this use case — representing restricted class hierarchies where the type is known at compile time.
The when exhaustiveness check is the language-level mechanism that enforces contract completeness.
Rationale and Alternatives
Why not direct method calls between contexts: Faster to implement. But creates compile-time coupling, hides interactions, and makes testing require full context mocks. Messages make every interaction visible, testable, and documented.
Why not a generic message bus with string-typed messages: Maximum decoupling — contexts don’t even share message type definitions. But no compile-time safety. A typo in a message name is a runtime error. A missing handler is a silent bug. Type safety matters more than decoupling for a system this size.
Why not Protocol Buffers or Avro for message definitions: Adds code generation, serialization overhead, and schema management tooling. Justified for cross-service communication. Unnecessary within a single JVM process where Kotlin types provide the same guarantees with zero overhead.
Why sealed and not open hierarchies: Open hierarchies allow extension without modifying the base. Useful for plugins. But the message catalog is a closed contract — every message is named, documented, and exhaustively handled. Opening the hierarchy would allow unnamed messages that bypass the catalog.
Why four hierarchies (Command, Query, Event, Notification) and not one: Different semantics require different handling. Commands want to change state and may be accepted, rejected, or ignored. Queries read state and must be answered. Events have many consumers and are never rejected — merely state facts. Notifications leave the domain. Collapsing them into fewer hierarchies would lose these semantic distinctions — and collapsing Commands and Queries would violate CQRS.
Consequences
-
Every interaction between contexts is visible in the message catalog. No hidden dependencies.
-
The compiler enforces exhaustive handling. Adding a new message variant requires updating every handler. Missing a handler is a compile error, not a runtime bug.
-
Message-based communication adds indirection compared to direct method calls. Each interaction requires constructing a message object and routing it. For current scale, the overhead is negligible.
-
The message catalog is the system’s API documentation. The architecture page and the code agree because they describe the same sealed hierarchies.
-
Testing is straightforward. To test a context, send it messages and verify the messages it produces. No mocking of other contexts needed.
-
The sealed hierarchy cannot be extended without modifying the source. This is a feature for contract integrity and a constraint for extensibility. If external plugins ever need to define custom messages, this decision would need to be revisited.