Organize all product strategy and domain modeling documentation into a dedicated .product-strategy directory for better separation from code. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
37 KiB
Domain Model: Namespace Isolation
Summary
The Namespace Isolation bounded context provides logical boundaries for event visibility and storage. It prevents cross-contamination of events between different logical scopes (tenants, domains, environments) while allowing trusted system components to observe across boundaries when needed. The core invariant is: events published to one namespace must be invisible to queries from different namespaces, except through explicit wildcard subscriptions by trusted components.
Unlike opinionated multi-tenancy frameworks, Namespace Isolation treats namespaces as primitives that applications define. The context provides the mechanism (filtering, storage isolation, pattern matching) without enforcing meaning. This aligns with Aether's principle of "Primitives Over Frameworks."
Invariants
Invariant: Namespace Boundary Isolation
- Rule: Events published to namespace X cannot be retrieved by queries to namespace Y (where X ≠ Y), using exact namespace matches
- Scope: Applies across both local EventBus and cross-node NATSEventBus subscriptions
- Why: Multi-scope deployments require data isolation to prevent information leakage and ensure compliance with tenant/domain boundaries
- Implementation:
- EventBus maintains separate subscriber lists per exact namespace (exactSubscribers[namespace])
- JetStreamEventStore creates separate streams per namespace (namespace_events format)
- NATS subjects are namespaced (aether.events.{namespace})
Invariant: Namespace Name Safety
- Rule: Namespace names must be safe for use as NATS subject tokens (no wildcards *, >, spaces, or dots)
- Scope: Applies at storage layer (JetStreamEventStore.SaveEvent) and pub/sub layer (EventBus.Subscribe)
- Why: NATS subject tokens have restrictions; invalid names corrupt subject patterns and break filtering
- Implementation: sanitizeSubject() replaces unsafe characters with underscores before using namespace in subjects
Invariant: Wildcard Subscriptions Bypass Isolation
- Rule: Subscriptions using wildcard patterns ("*", ">") deliberately receive events from multiple namespaces
- Scope: Applies to EventBus.Subscribe(pattern) and NATSEventBus subscriptions
- Why: Cross-cutting concerns (logging, monitoring, auditing) need visibility across boundaries
- Exception: Only trusted system components should be granted wildcard access
- Implementation: EventBus separates exactSubscribers from wildcardSubscribers; wildcards are documented as security concerns
Invariant: Subject Pattern Matching Consistency
- Rule: Namespace patterns must be evaluated consistently using NATS-style token matching across all layers
- Scope: Applies to MatchNamespacePattern(), EventBus pub/sub routing, and NATS subject generation
- Why: Inconsistent matching creates gaps where events are published but not delivered, or vice versa
- Implementation: MatchNamespacePattern() enforces token-based matching (dots separate tokens, "*" matches one token, ">" matches multiple)
Aggregates
Note: This context has NO aggregates. Namespace is not an aggregate because it has no invariants of its own to enforce. It's a primitive value object used by other contexts' aggregates.
Instead, the context defines:
- Value Objects for namespace concepts
- Commands for registering/configuring namespaces
- Policies for enforcing isolation at the storage and pub/sub layers
- Read Models for querying events within a namespace
This is intentional: Aether provides primitives, not opinionated domain models.
Value Objects
Namespace
A logical boundary identifier. Immutable, defined by its string value.
type Namespace string
// Characteristics:
// - No identity beyond the string value
// - Equality is name-based: Namespace("tenant-a") == Namespace("tenant-a")
// - May be hierarchical using dots: "prod.orders", "staging.tenant-123"
// - Application defines meaning (tenant, domain, environment, etc.)
Validation:
- Should not be empty (semantic meaning required)
- Should not contain unsafe NATS subject characters (sanitized automatically)
- May be hierarchical (dots allowed) for applications using structured naming
Usage:
- Passed to EventBus.Subscribe(namespace)
- Passed to NATSEventBus subscriptions
- Configured in JetStreamEventStore.NewJetStreamEventStoreWithNamespace(namespace)
- Embedded in Event.Namespace (not currently in codebase, but design implication)
SubjectPattern
A pattern for matching one or more namespaces. Follows NATS conventions.
type SubjectPattern string
// Examples:
// "tenant-a" - Exact match to one namespace
// "*" - Single wildcard: any namespace with no dots
// "prod.*" - Match "prod.orders", "prod.users", but not "prod.orders.legacy"
// "prod.>" - Match "prod", "prod.orders", "prod.orders.legacy", etc.
// ">" - Global wildcard: all namespaces
Matching Rules:
- Tokens separated by dots
- "*" matches exactly one token (anything except dots)
- ">" matches one or more tokens (only valid at end)
- Exact strings match exactly
Security: Wildcard patterns bypass namespace isolation. Only trusted components should use them.
NamespaceFilter
Represents a boundary constraint for queries.
type NamespaceFilter {
// Exact namespace to query (no wildcards)
Namespace string
}
Constraint: Exact namespaces only. When an application explicitly queries a namespace, they should not accidentally get data from multiple namespaces.
Commands
Command: DefineNamespace
- Aggregate: None (Namespace is a value, not an aggregate)
- Input: Namespace name (string)
- Validates:
- Namespace is not empty
- Namespace contains only safe characters (or accepts and sanitizes)
- Invariant enforced: Namespace Name Safety
- Success: Namespace can be used in Subscribe() and CreateEventStore(namespace)
- Failure: Invalid format → reject, never silently sanitize in application layer
Example (Conceptual):
// Application defines namespace during initialization
namespace := "tenant-abc" // Validated by application logic
store := store.NewJetStreamEventStoreWithNamespace(natsConn, "events", namespace)
eventBus := aether.NewEventBus()
Command: PublishToNamespace
- Aggregate: None (action on EventBus, which is not an aggregate)
- Input:
- Namespace: The target namespace (exact match)
- Event: Domain event to publish
- Validates:
- Namespace is defined
- Event is valid (has ID, EventType, ActorID, Version)
- Invariant enforced: Namespace Boundary Isolation (ensures event goes only to exact subscribers)
- Success: Event published to all exact subscribers of this namespace + matching wildcard subscribers
- Failure: Publication error → log and metric
Example:
event := &aether.Event{
ID: uuid.New().String(),
EventType: "OrderPlaced",
ActorID: "order-123",
Version: 1,
Data: map[string]interface{}{"total": 100.00},
Timestamp: time.Now(),
}
eventBus.Publish("tenant-abc", event) // Only tenant-abc subscribers receive this
Command: SubscribeToNamespace
- Aggregate: None
- Input:
- NamespacePattern: Pattern for namespaces to receive (can include wildcards)
- SubscriptionFilter: Optional filter for event types and actors
- Validates:
- Pattern is valid NATS subject format
- If pattern is exact (no wildcards), enforce Namespace Boundary Isolation
- If pattern is wildcard, document as cross-boundary visibility
- Invariant enforced:
- Namespace Boundary Isolation (exact patterns receive only matching namespace events)
- Wildcard Subscriptions Bypass Isolation (wildcard patterns are intentional exceptions)
- Success: Channel created that receives matching events
- Failure: Pattern error → reject, pattern still matches incorrectly → silent miss (events lost)
Example:
// Exact subscription - receives only tenant-abc events
ch := eventBus.Subscribe("tenant-abc")
// Wildcard subscription - receives from all prod.* namespaces
// CAUTION: Bypasses namespace isolation for logging/auditing
ch := eventBus.Subscribe("prod.*") // Receives prod.orders, prod.users, etc.
// With filter
filter := &aether.SubscriptionFilter{
EventTypes: []string{"OrderPlaced"},
ActorPattern: "order-*",
}
ch := eventBus.SubscribeWithFilter("tenant-abc", filter)
Command: CreateNamespacedEventStore
- Aggregate: None
- Input:
- StreamName: Base stream name (e.g., "events")
- Namespace: Logical boundary for storage isolation
- Config: Optional JetStreamConfig (retention, replicas)
- Validates:
- Namespace is not empty (though empty means non-namespaced for backward compat)
- Namespace contains only characters safe for stream names
- Invariant enforced: Namespace Boundary Isolation (separate JetStream streams per namespace)
- Success: JetStreamEventStore created with effective stream name "namespace_streamName"
- Failure: Cannot create stream → return error
Example:
// Creates stream: "tenant-abc_events"
store1, _ := store.NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant-abc")
// Creates stream: "tenant-def_events"
store2, _ := store.NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant-def")
// Events in store1 are completely isolated from store2 (different NATS streams)
Events
Event: NamespaceCreated (Conceptual - Not Currently Published)
- Triggered by: Application initialization or registration endpoint
- Aggregate: None (system event, not domain-driven)
- Data: namespace, createdAt, configuredBy
- Consumed by: Auditing, monitoring, provisioning systems
- Note: Currently not part of Aether's event model; application layer concern
Event: EventPublished
- Triggered by: PublishToNamespace command
- Aggregate: None (system event)
- Data: The Event itself, namespace it was published to, timestamp
- Consumed by: All subscribers matching the namespace pattern
- Note: In Aether, events are published directly; namespace is routing metadata, not event data
Event: SubscriptionCreated (Conceptual)
- Triggered by: SubscribeToNamespace command
- Aggregate: None
- Data: namespacePattern, filter, subscriberID
- Consumed by: Auditing (who is subscribing to what data?)
- Note: Not currently published; application concern for access control
Policies
Policy: Namespace Event Routing
- Trigger: When PublishToNamespace(namespace, event) is called
- Action:
- Deliver to all exactSubscribers[namespace] matching the event filter
- Deliver to all wildcardSubscribers where pattern matches namespace
- Context: Enforces Namespace Boundary Isolation while allowing intentional cross-boundary access
- Implementation: EventBus.Publish() → EventBus.deliverToSubscriber() (checks filters, applies AND logic)
Policy: NATS Subject Namespacing
- Trigger: When NATSEventBus.Publish(namespace, event) is called
- Action: Publish to NATS subject "aether.events.{namespace}" (exact namespace only, no wildcards)
- Context: Ensures cross-node events respect namespace boundaries
- Implementation:
subject := fmt.Sprintf("aether.events.%s", namespaceID) neb.nc.Publish(subject, eventMessage)
Policy: NATS Subscription Pattern Replication
- Trigger: When NATSEventBus.SubscribeWithFilter(pattern, filter) is called
- Action: Create NATS subscription to subject "aether.events.{pattern}" with native NATS wildcard support
- Context: Leverages NATS server-side filtering for efficiency; patterns apply at NATS level
- Implementation:
subject := fmt.Sprintf("aether.events.%s", namespacePattern) // Pattern includes wildcards neb.nc.Subscribe(subject, handleNATSEvent) - Note: NATS natively supports wildcards, so patterns work at the NATS subject level, not just in EventBus
Policy: Storage Stream Isolation
- Trigger: When CreateNamespacedEventStore(namespace) is called
- Action: Create separate JetStream stream with name "{namespace}_{streamName}"
- Context: Provides storage-layer isolation (complete data separation)
- Implementation:
effectiveStreamName := fmt.Sprintf("%s_%s", sanitizeSubject(namespace), streamName) streamConfig.Name = effectiveStreamName js.AddStream(streamConfig) - Result: Events in one namespace's stream cannot be read by querying another namespace's store
Policy: Subject Sanitization
- Trigger: When namespace or actor ID is used in a NATS subject
- Action: Replace unsafe characters (space, dot, *, >) with underscores
- Context: Ensures all subject components are valid NATS tokens
- Implementation: sanitizeSubject() called before formatting subjects
- Example: "prod.orders" → "prod_orders" when used as stream name prefix
Policy: Wildcard Warning and Audit
- Trigger: When IsWildcardPattern(pattern) returns true
- Action: Log warning and record metric, document in comments that this bypasses isolation
- Context: Intentional but requires awareness; prevents accidental exposure
- Implementation:
if IsWildcardPattern(namespacePattern) { log.Printf("[NATSEventBus] Wildcard subscription: %s", pattern) metrics.RecordWildcardSubscription(pattern) }
Read Models
Read Model: GetEventsInNamespace
- Purpose: Retrieve all events for an actor within a specific namespace
- Query: GetEvents(store, actorID, fromVersion)
- Data: List[Event] with ID, EventType, ActorID, Version, Data, Timestamp
- Source: JetStreamEventStore (or InMemoryEventStore for tests)
- Updated: Immediately after SaveEvent
- Invariant: Returns events only from specified namespace; cross-namespace queries return empty
- Implementation:
// Queries only the namespace's stream store := NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant-abc") events, _ := store.GetEvents("order-123", 0) // Only events in tenant-abc_events stream
Read Model: SubscriberCountPerNamespace
- Purpose: Operational visibility into subscription load
- Query: EventBus.SubscriberCount(namespace) or WildcardSubscriberCount()
- Data: int (count of active subscribers)
- Source: EventBus internal state (exactSubscribers, wildcardSubscribers)
- Updated: When Subscribe/Unsubscribe called
- Usage: Monitoring, scaling decisions
- Implementation: Returns len(exactSubscribers[namespace]) (not including wildcard subscribers)
Read Model: ActiveNamespacePatterns
- Purpose: See which patterns are being subscribed to
- Query: Implicit in EventBus.patternSubscribers (NATSEventBus)
- Data: Map[pattern] → count
- Source: NATSEventBus internal tracking
- Updated: When first/last subscriber for pattern arrives
- Usage: Auditing access patterns, understanding data flow
Read Model: EventReplay (with errors)
- Purpose: Reconstruct actor state from event stream, with error handling
- Query: GetEventsWithErrors(actorID, fromVersion) → ReplayResult
- Data: {Events: [Event], Errors: [ReplayError]}
- Source: JetStreamEventStore (implements EventStoreWithErrors)
- Updated: On each query (immutable event log)
- Usage: State reconstruction, data quality monitoring
- Implementation: ReplayResult separates successfully parsed events from malformed ones
Code Analysis: Brownfield (Existing Implementation)
Current State vs. Intended Domain Model
EventBus (eventbus.go)
Intended: Value object for namespace patterns; routing policy Actual: Struct with local subscriptions, pattern matching, filtering Alignment: Good
- Exact subscribers and wildcard subscribers are separated (lines 69-71)
- Filtering is applied uniformly (SubscriptionFilter with EventTypes and ActorPattern)
- Pattern matching correctly delegates to MatchNamespacePattern()
- Metrics collection is present
Found:
type EventBus struct {
exactSubscribers map[string][]*filteredSubscription // Per namespace
wildcardSubscribers []*filteredSubscription // Cross-namespace
mutex sync.RWMutex
}
Strengths:
- Clear separation of exact vs. wildcard
- Thread-safe with RWMutex
- Buffered channels prevent blocking (capacity 100)
- Metrics tracking
Misalignment:
- Filter matching is implicit in deliverToSubscriber(); document that it's AND logic
- No explicit "namespace boundary enforced here" comment for clarity
NATSEventBus (nats_eventbus.go)
Intended: Extend EventBus with cross-node replication; apply policies at NATS level Actual: Wraps EventBus, creates NATS subscriptions per pattern, deduplicates local events Alignment: Good
- Namespace pattern is used directly as NATS subject suffix (line 89)
- Local events are skipped (line 141: eventMsg.NodeID == neb.nodeID)
- Wildcard subscribers handled specially (lines 148-150)
- Pattern tracking ensures NATS subscription cleanup
Found:
subject := fmt.Sprintf("aether.events.%s", namespacePattern) // Patterns include wildcards
neb.nc.Subscribe(subject, func(msg *nats.Msg) {
neb.handleNATSEvent(msg, subscribedPattern)
})
Strengths:
- Leverages NATS native wildcards correctly
- Deduplication avoids local events being delivered twice
- Reference counting (patternSubscribers) ensures proper cleanup
Misalignment:
- No explicit sanitization of namespace before using in subject (relies on EventBus validation)
- handleNATSEvent distinguishes between wildcard and exact patterns (lines 148-154), but this logic could be clearer
JetStreamEventStore (store/jetstream.go)
Intended: Provide storage-layer isolation; enforce namespace safety; implement EventStore interface Actual: JetStream wrapper with namespace prefix, version management, snapshot support Alignment: Excellent
- Namespace config applied to stream name (line 83: "namespace_streamName")
- sanitizeSubject() applied to namespace before formatting subject (line 83)
- Subjects include namespace prefix: "{namespace}_{streamName}.events.{actorType}.{actorID}" (lines 148-151)
- Version concurrency control with mutex
Found:
config := JetStreamConfig{Namespace: "tenant-abc"}
effectiveStreamName := fmt.Sprintf("%s_%s",
sanitizeSubject(config.Namespace), // Sanitize namespace
streamName) // "tenant-abc_events"
Strengths:
- Storage-layer isolation (separate streams per namespace)
- Namespace is optional (backward compatible)
- Sanitization prevents NATS subject injection
- Clear constructor: NewJetStreamEventStoreWithNamespace()
Misalignment:
- sanitizeSubject() is private to store package; document that namespaces must be validated by application before reaching storage layer
- No explicit check that namespace-a_events and namespace-b_events are different streams (implicit in NATS stream concept, but should be tested)
Pattern Matching (pattern.go)
Intended: Enforce Subject Pattern Matching Consistency invariant Actual: MatchNamespacePattern(), MatchActorPattern(), IsWildcardPattern() Alignment: Excellent
- Token-based matching with dots as separators (lines 38-41)
- "*" matches one token; ">" matches one or more (lines 54-56, 67-68)
- Special case: ">" alone matches any non-empty namespace (lines 34-35)
Found:
func MatchNamespacePattern(pattern, namespace string) bool {
// ">" matches everything when used alone
if pattern == ">" {
return namespace != "" // One or more tokens
}
patternTokens := strings.Split(pattern, ".")
namespaceTokens := strings.Split(namespace, ".")
return matchTokens(patternTokens, namespaceTokens)
}
Strengths:
- Consistent with NATS token matching
- Recursive token-by-token matching is clear
- IsWildcardPattern() simple and correct
Misalignment:
- MatchActorPattern() has two code paths (token-based for dot-separated, simple for non-dot) which is pragmatic but complex (lines 156-197)
- No documentation of the two-path design
SubscriptionFilter (pattern.go)
Intended: Composable filtering with AND logic Actual: EventTypes (OR within list) + ActorPattern, both optional Alignment: Good
- IsEmpty() correctly checks both conditions (lines 111-113)
- Matches() applies AND logic (all filters must pass)
- EventType matching is OR (line 126: "typeMatch := true")
- ActorPattern uses MatchActorPattern()
Found:
type SubscriptionFilter struct {
EventTypes []string // OR within this list
ActorPattern string // AND with EventTypes
}
func (f *SubscriptionFilter) Matches(event *Event) bool {
// Check event type filter (OR logic within types)
if len(f.EventTypes) > 0 {
typeMatch := false
for _, et := range f.EventTypes {
if event.EventType == et {
typeMatch = true
break
}
}
if !typeMatch { return false } // AND: must match type
}
// Check actor pattern (AND)
if f.ActorPattern != "" {
if !MatchActorPattern(f.ActorPattern, event.ActorID) {
return false
}
}
return true
}
Strengths:
- Clear optional filtering
- AND/OR logic is correct and documented
Misalignment: None significant.
Namespace Isolation Not Yet in Codebase
The following concepts are designed but not implemented:
-
Namespace as metadata in Event struct
- Current: Event has no Namespace field
- Intended: Events should carry their namespace for audit/tracing
- Impact: Currently namespace is transport/routing metadata, not event data
- Refactoring: Add
Namespace stringto Event struct (backward compat via omitempty JSON)
-
NamespacedEventBus (namespace-aware bus wrapper)
- Current: Applications manage namespaces externally
- Intended: EventBus could enforce a single namespace per instance
- Impact: Currently developer must pass namespace correctly; bus doesn't validate
- Refactoring: Create wrapper that binds to namespace and prevents accidental cross-namespace access
-
Namespace validation in application layer
- Current: No validation before DefineNamespace
- Intended: Application should reject invalid namespace formats
- Impact: Invalid names are silently sanitized at storage layer
- Refactoring: Add namespace.Validate() function and document conventions
Safety Documentation: Wildcard Subscriptions
Risk: Data Exposure via Wildcard Patterns
Wildcard subscriptions intentionally bypass namespace isolation to enable cross-cutting concerns.
Scenarios Where Wildcards Are Safe:
- Trusted logging/auditing system (requires admin access)
- Ops monitoring across all namespaces (requires Ops team authorization)
- Internal distributed tracing (requires service-to-service authentication)
Scenarios Where Wildcards Are Dangerous:
- User-facing API that accepts subscription patterns from external clients
- Tenant-specific code that accidentally uses ">" pattern
- Feature flag controlling subscription pattern (default should be exact, not wildcard)
Mitigation:
- Log and alert on wildcard subscriptions (NATSEventBus does this)
- Code review any use of "*" or ">" in subscription patterns
- Restrict wildcard subscription to admin/ops code paths
- Test that cross-namespace queries fail without wildcards
- Document in API that patterns are NATS-style and may cross boundaries
Code Locations and Warnings
| Location | Purpose | Risk |
|---|---|---|
| eventbus.go lines 10-16 | EventBroadcaster docs | Documents wildcard behavior |
| eventbus.go lines 63-66 | EventBus struct docs | Notes wildcard as intentional |
| nats_eventbus.go lines 15-20 | NATSEventBus docs | Warns about wildcard bypass |
| pattern.go lines 19-26 | MatchNamespacePattern docs | Security considerations |
| pattern.go lines 101-102 | Subscribe() docs | Explicitly warns about wildcards |
Good Examples:
// Exact subscription - safe, enforces boundary
ch := bus.Subscribe("tenant-abc")
// Wildcard for logging - document intent clearly
// CAUTION: Logs events from all namespaces
ch := bus.Subscribe(">") // Comment required!
Bad Examples:
// Unclear whether wildcard is intentional
pattern := getTenantFilter() // Returns ">" by default? Dangerous!
ch := bus.Subscribe(pattern)
// API accepting patterns from users
func SubscribeToEvents(pattern string) <-chan Event {
return bus.Subscribe(pattern) // User can pass ">" and bypass isolation!
}
Refactoring Backlog: Aligning Implementation with Model
Issue 1: Add Namespace as Event Metadata
Current: Events carry no namespace information Intended: Events should record which namespace they belong to for audit trail Impact: Medium - enables better tracing, doesn't break existing code Steps:
- Add
Namespace stringfield to Event struct (JSON: "namespace") - Update SaveEvent() to set event.Namespace = namespace
- Update GetEvents() to filter by namespace (redundant but defensive)
- Update tests to include namespace in event fixtures
Acceptance Criteria:
- Event.Namespace is populated when stored via EventStore
- Replayed events have namespace metadata
- Audit logs include namespace for all events
Issue 2: Validate Namespace Format at Application Layer
Current: Invalid namespace names are silently sanitized at storage layer Intended: Application should explicitly validate and reject invalid names Impact: Low - improves error messages, prevents silent data transformation Steps:
- Create Namespace type alias and validator function
- Document namespace conventions (no spaces, no dots, etc., or define hierarchical format)
- Update NewJetStreamEventStoreWithNamespace() to require validated namespace
- Add examples of valid/invalid namespaces
Acceptance Criteria:
- NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant abc") returns error
- NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant-abc") succeeds
- Error messages explain what characters are invalid
Issue 3: Create NamespacedEventBus Wrapper
Current: EventBus is generic; application must manage namespace per instance Intended: Wrapper enforces single namespace per bus instance Impact: Medium - improves API safety, adds convenience layer Steps:
- Create NamespacedEventBus(bus *EventBus, namespace string)
- Override Subscribe() to enforce exact namespace only (reject wildcards at construction time)
- Override Publish() to validate namespace matches
- Update examples to show NamespacedEventBus usage
Acceptance Criteria:
- NamespacedEventBus.Subscribe() rejects wildcard patterns
- NamespacedEventBus.Publish() verifies namespace matches
- No cross-namespace access possible through this wrapper
Issue 4: Document Namespace Hierarchies
Current: Namespace is opaque string; hierarchical naming (prod.orders) is unsupported in docs Intended: Support structured namespaces for domain/environment hierarchies Impact: Low - documentation, no code changes Steps:
- Document hierarchical naming convention (domain.environment.tenant)
- Provide examples with pattern matching
- Show how to query "all production namespaces" vs "single tenant"
- Warn about dots in namespace names being sanitized at JetStream level
Acceptance Criteria:
- Architecture docs explain hierarchical namespace design
- Examples show queries like "prod.*", "prod.orders.>", etc.
- Clear warning about behavior after sanitization (dots become underscores in stream name)
Issue 5: Test Cross-Namespace Isolation
Current: namespace_test.go covers stream naming; no integration tests for isolation Intended: Integration tests verify isolation breach is prevented Impact: High - confidence in safety invariant Steps:
- Create namespace_integration_test.go
- Test SaveEvent in store1 (tenant-abc_events) is not visible in store2 (tenant-def_events)
- Test Publish(tenant-abc) is not received by Subscribe(tenant-def)
- Test Publish(tenant-abc) is received by Subscribe("prod.*") if tenant-abc matches pattern
- Test wildcard subscriptions deliver correctly across namespaces
Acceptance Criteria:
- SaveEvent(store1, event) → GetEvents(store2, event.ActorID) returns empty
- Publish(tenant-a, event) → Subscribe(tenant-b) receives nothing
- Publish(tenant-a, event) → Subscribe(">") receives it
- Pattern matching verified for multi-node setups
Issue 6: Audit Wildcard Subscriptions
Current: Wildcard subscriptions are logged but not tracked long-term Intended: Audit trail of who subscribed to what patterns Impact: Low - security audit trail Steps:
- Create SubscriptionAuditLog interface
- NATSEventBus calls AuditLog.RecordWildcardSubscription(nodeID, pattern, timestamp)
- Provide in-memory and persistent implementations
- Add documentation: "Enable audit logging for compliance"
Acceptance Criteria:
- Wildcard subscriptions are recorded with timestamp and node
- Audit log includes pattern, not just count
- Clear documentation on enabling audit logging
Testing Strategy: Namespace Isolation
Unit Tests (Existing: pattern_test.go, namespace_test.go)
- MatchNamespacePattern() with various dot-separated tokens ✓
- IsWildcardPattern() detection ✓
- sanitizeSubject() character replacement ✓
- SubscriptionFilter AND/OR logic
- EventBus routing to exact vs. wildcard subscribers
Integration Tests (Needed)
Test: Storage Layer Isolation
// Create two namespaced stores
store1 := NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant-abc")
store2 := NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant-def")
// Save event in store1
event1 := Event{ID: "1", ActorID: "order-123", Version: 1, ...}
store1.SaveEvent(event1)
// Verify store2 cannot see it
events, _ := store2.GetEvents("order-123", 0)
assert.Empty(events) // MUST be empty
Test: Pub/Sub Exact Namespace
bus := NewEventBus()
ch1 := bus.Subscribe("tenant-abc")
ch2 := bus.Subscribe("tenant-def")
event := Event{ID: "1", ActorID: "order-123", ...}
bus.Publish("tenant-abc", event)
assert.Receives(ch1, event) // MUST receive
assert.Empty(ch2) // MUST NOT receive
Test: Pub/Sub Wildcard Pattern
bus := NewEventBus()
chExact := bus.Subscribe("tenant-abc")
chWildcard := bus.Subscribe("*") // Intentional bypass
event := Event{ID: "1", ...}
bus.Publish("tenant-abc", event)
assert.Receives(chExact, event) // Exact subscriber
assert.Receives(chWildcard, event) // Wildcard subscriber
Test: Cross-Node NATS Isolation
neb1 := NewNATSEventBus(natsConn1)
neb2 := NewNATSEventBus(natsConn2) // Different node
ch1 := neb1.Subscribe("tenant-abc")
ch2 := neb2.Subscribe("tenant-def")
event := Event{ID: "1", ...}
neb1.Publish("tenant-abc", event)
// Wait for cross-node delivery
assert.Receives(ch1, event) // Local delivery
assert.Eventually(event in ch1, timeout) // Remote node receives
assert.Empty(ch2) // Different namespace blocked at NATS level
Test: Pattern Matching Consistency
// Ensure MatchNamespacePattern() matches NATS behavior
tests := []struct {
pattern, namespace string
match bool
}{
("prod.orders", "prod.orders", true),
("prod.*", "prod.orders", true),
("prod.*", "prod.orders.legacy", false),
("prod.>", "prod.orders.legacy", true),
(">", "prod.orders", true),
("*", "prod.orders", false), // "*" doesn't match dots
}
Design Decisions and Rationale
Why Namespace is Not an Aggregate
Decision: Namespace is a value object, not an aggregate with invariants.
Rationale:
- Namespace has no lifecycle (no creation, deletion, state changes)
- Namespace carries no invariants (e.g., "namespace membership" has no rules)
- Aether philosophy: Primitives, not opinionated frameworks
- Application defines namespace meaning, not Aether
Alternative Considered: Make Namespace an aggregate with events like NamespaceCreated, NamespaceDeleted Rejected: Adds complexity without domain benefit; namespace is infrastructure, not business concept
Why Wildcards Are Allowed Despite Isolation
Decision: Wildcard patterns intentionally bypass isolation, documented as security concern.
Rationale:
- Cross-cutting concerns need visibility: logging, monitoring, tracing, auditing
- Trusted system components (Ops, admin code) need to observe across boundaries
- Impossible to provide complete isolation while also supporting observability
- Explicit in code (comments, docs) is safer than implicit restriction
Alternative Considered: Remove wildcard support, force separate subscriptions per namespace Rejected: Would require application code to manage M*N subscriptions; wildcard is standard NATS pattern
Why Namespace Is a String, Not a Type
Decision: Namespace is string, not a branded type (type Namespace string).
Rationale:
- Flexibility: Application defines naming conventions (no framework opinions)
- Go compatibility: String type works with all NATS and storage APIs
- Zero cost abstraction
- Reduced coupling: changing namespace format doesn't require type changes
Alternative Considered: Strong type NewNamespace("tenant-abc") to enforce validation Rejected: Primitives over frameworks; validation is application concern
Why Sanitization Is at Storage Layer, Not Application
Decision: JetStreamEventStore.SaveEvent() sanitizes namespace via sanitizeSubject().
Rationale:
- Fail-safe: Prevents NATS subject injection even if application doesn't validate
- Single point of truth: All storage goes through same sanitization
- Backward compatible: Existing namespaces still work if stored differently
Consequence: Applications see original namespace, but stream names are sanitized Example: Namespace "prod.orders" is stored in stream "prod_orders_events"
Alternative Considered: Require application to validate and reject invalid namespaces Rejected: Would require documenting validation rules in multiple places; sanitization is safer
Why JetStream Streams Are Per-Namespace, Not Per-Actor
Decision: Stream name includes namespace prefix: "{namespace}_{streamName}"
Rationale:
- Complete storage isolation: Events in one namespace's stream cannot be read from another
- Simpler configuration: Application chooses one namespace per application instance
- Multi-namespace within same store: Not supported (would reduce isolation)
Trade-off: Multiple streams consume more JetStream resources but provide stronger isolation
Alternative Considered: Single stream with namespace as message metadata filter Rejected: Application-side filtering is not as secure; NATS stream-level isolation is stronger
Alignment with Aether Vision
Primitives Over Frameworks
Namespace Isolation provides:
- Namespace (value): String identifier for boundaries
- SubscriptionFilter (value): Optional filtering rules
- EventBus.Subscribe(pattern) (primitive): Core pub/sub with pattern matching
- EventStore.SaveEvent(event) (primitive): Core persistence with namespace isolation built-in
Does NOT provide:
- Opinionated multi-tenancy framework
- Tenant lifecycle management
- Namespace quota enforcement
- Permission-based namespace access control
Applications build these on top of primitives.
NATS-Native
- Subject patterns: Uses NATS native "*" and ">" wildcards
- JetStream streams: Separate stream per namespace leverages NATS architecture
- Node-to-node: NATSEventBus publishes with namespace in subject prefix
- No abstraction layer: Direct use of NATS concepts, not hidden
Resource Conscious
- Minimal overhead: Namespace is just a string; filtering is O(subscriber count)
- Efficient patterns: NATS server-side filtering with subjects
- No namespace registry: Namespaces are defined implicitly by use
- Optional: Namespace is opt-in; backward compatible with no namespace
Events as Complete History
- Immutable per namespace: Events in namespace X cannot be modified
- Complete audit trail: All events visible via replay (filtered by namespace)
- Namespace metadata: Events could (should) record their namespace
- No deletion: Namespaces don't have "cleanup" commands; events persist per retention policy
Recommendations for Implementation
Priority 1: Storage-Level Isolation Tests
Why: Highest risk; must ensure separate streams truly isolate data Effort: 1-2 days Impact: Confidence in safety invariant
Priority 2: Add Namespace to Event Metadata
Why: Needed for audit trails; enables namespace field in logs Effort: 2-3 days Impact: Better observability
Priority 3: NamespacedEventBus Wrapper
Why: Improves API safety; prevents accidental wildcard subscriptions Effort: 2-3 days Impact: Easier to use safely
Priority 4: Document Namespace Hierarchies
Why: Required for multi-scope deployments; clarifies intended patterns Effort: 1 day Impact: Clear guidance for applications
Priority 5: Audit Logging for Wildcards
Why: Security audit trail; helps detect unauthorized access patterns Effort: 3-4 days Impact: Compliance and monitoring
References
-
Code Location:
/Users/hugo.nijhuis/src/github/flowmade-one/aether/ -
Key Files:
- eventbus.go (lines 10-268)
- nats_eventbus.go (lines 1-231)
- pattern.go (lines 1-197)
- store/jetstream.go (lines 1-382)
- store/namespace_test.go (lines 1-125)
-
Vision Alignment: /aether/vision.md (Primitives Over Frameworks, NATS-Native)
-
CLAUDE.md: /aether/CLAUDE.md (namespace isolation context from bounded context map)