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>
15 KiB
Namespace Isolation: Summary and Implementation Gaps
Executive Summary
The Namespace Isolation bounded context provides logical boundaries for event visibility and storage in Aether. The implementation is mostly complete with strong primitives for exact namespace matching and NATS-native wildcard support. The main gaps are metadata tracking (namespace field on events) and application-layer validation (explicit namespace format checking).
Status: Core invariants are enforced; refactoring needed for observability and safety.
Core Invariants: Enforcement Status
| Invariant | Status | Evidence |
|---|---|---|
| Namespace Boundary Isolation | ✓ Enforced | EventBus separates exactSubscribers by namespace; JetStream streams are per-namespace |
| Namespace Name Safety | ✓ Enforced | sanitizeSubject() applied at storage layer; NATS subject injection prevented |
| Wildcard Subscriptions Bypass Isolation | ✓ Documented | Explicit in code comments and docstrings (eventbus.go lines 15-16, 101-102) |
| Subject Pattern Matching Consistency | ✓ Enforced | MatchNamespacePattern() consistent with NATS token-based matching |
Implementation Alignment
EventBus: Exact and Wildcard Routing
Architecture:
EventBus
├── exactSubscribers: map[namespace] → [subscriber] (isolation enforced)
├── wildcardSubscribers: [subscriber] (cross-boundary)
└── Publish(namespace) → delivers to both groups (with filter matching)
Lines: eventbus.go 69-71, 131-137, 186-205 Invariant Enforced: Namespace Boundary Isolation
Strengths:
- Exact subscribers only receive from their namespace
- Wildcard subscribers are separate list (intentional, documented)
- Filter matching is AND logic (events must pass all filters)
Gap Identified:
- No explicit comment explaining separation strategy
- Could document: "exactSubscribers enforce isolation; wildcardSubscribers are intentional exceptions"
NATSEventBus: Cross-Node Replication
Architecture:
NATSEventBus (extends EventBus)
├── Subscribe(pattern) → creates NATS subject "aether.events.{pattern}"
├── Publish(namespace, event) → publishes to "aether.events.{namespace}"
└── handleNATSEvent() → deduplicates local events, delivers to wildcard subscribers
Lines: nats_eventbus.go 63-111, 186-211 Invariant Enforced: Namespace Boundary Isolation (at NATS subject level)
Strengths:
- NATS subjects include namespace as prefix
- Local node events are deduplicated (not re-delivered)
- Wildcard patterns work via NATS native subject matching
- Pattern tracking prevents duplicate NATS subscriptions
Gap Identified:
- No sanitization of namespace before formatting NATS subject (relies on EventBus callers to pass valid namespaces)
- Mitigation: JetStreamEventStore sanitizes at storage layer (defense in depth)
JetStreamEventStore: Storage-Layer Isolation
Architecture:
JetStreamEventStore
├── Namespace: "tenant-abc" → Stream: "tenant_abc_events"
├── Subject: "{namespace}_{streamName}.events.{actorType}.{actorID}"
└── SaveEvent() → publishes only to namespaced stream
Lines: store/jetstream.go 66-106, 148-157 Invariants Enforced: Namespace Boundary Isolation, Namespace Name Safety
Strengths:
- Separate JetStream streams per namespace (complete storage isolation)
- sanitizeSubject() applied to namespace before stream name (lines 83)
- GetEvents() queries only the configured stream (cannot see other namespaces)
- NewJetStreamEventStoreWithNamespace() has clear, explicit constructor
Gaps Identified:
-
No namespace field in Event struct
- Current: namespace is transport metadata, not event data
- Implication: Replayed events don't carry their namespace
- Risk: Difficult to audit which namespace an event belonged to
- Refactoring: Add
Namespace stringfield to Event (JSON: "namespace", omitempty)
-
No namespace validation before CreateNamespacedEventStore
- Current: Invalid names are silently sanitized
- Example: NewJetStreamEventStoreWithNamespace(conn, "events", "tenant.abc") succeeds, but stream becomes "tenant_abc_events"
- Implication: Behavior change is silent; developer thinks they're using "tenant.abc" but storage uses "tenant_abc"
- Refactoring: Add Namespace.Validate() function; reject invalid names with clear errors
Pattern Matching: Token-Based Consistency
Implementation: pattern.go 27-77 Invariant Enforced: Subject Pattern Matching Consistency
Strengths:
- Token-based matching (dot-separated) matches NATS conventions
- "*" matches exactly one token
- ">" matches one or more tokens (only at end)
- Recursive matching is clear and correct
Gaps Identified:
-
MatchActorPattern has two code paths
- Simple patterns (no dots): prefix/suffix wildcard matching (lines 183-193)
- Complex patterns (with dots): token-based matching via MatchNamespacePattern()
- Implication: Inconsistency between actor ID patterns and namespace patterns
- Example: "order-" matches "order-123" but "order." matches "order.us.east"
- Mitigation: Document the difference; clarify when to use dots vs. prefixes
-
IsWildcardPattern is simple but misses hierarchical wildcards
- Current: Checks for "*" or ">" anywhere in pattern
- Fine for EventBus (correct)
- No issue, but worth documenting for future use
SubscriptionFilter: Composable Filtering
Implementation: pattern.go 85-144 Usage: EventBus.SubscribeWithFilter(), NATSEventBus filtering
Strengths:
- EventTypes filter is OR (any one matches)
- ActorPattern filter is AND with EventTypes
- IsEmpty() correctly detects no filters
No Gaps: Filtering is correctly implemented.
Refactoring Priorities
P1: Add Namespace to Event Metadata (2-3 days)
Why: Required for observability and audit trails
Changes:
type Event struct {
ID string
EventType string
ActorID string
Namespace string // ← NEW: Logical boundary this event belongs to
Version int64
Data map[string]interface{}
Timestamp time.Time
Metadata map[string]string
}
Impact:
- Enables:
event.Namespacein replayed events - Enables: Filtering on namespace in read models
- Enables: Clear audit trail of which namespace an event came from
- Breaking: None (new field, optional in JSON via omitempty)
Testing: Verify namespace is set during SaveEvent() and persists through replay
P2: Add Namespace Validation (1 day)
Why: Prevent silent behavior changes from namespace sanitization
Changes:
// In application or aether package
func ValidateNamespace(ns string) error {
if ns == "" {
return errors.New("namespace cannot be empty")
}
// Allow alphanumeric, hyphens, underscores, dots for hierarchies
if !regexp.MustCompile(`^[a-zA-Z0-9._-]+$`).MatchString(ns) {
return fmt.Errorf("namespace contains invalid characters: %q", ns)
}
return nil
}
// Update constructors
func NewJetStreamEventStoreWithNamespace(conn, streamName, namespace string) error {
if err := ValidateNamespace(namespace); err != nil {
return err // Reject immediately, don't sanitize silently
}
// ... rest of initialization
}
Impact:
- Prevents: Silent sanitization of invalid names
- Enables: Clear error messages for misconfigured namespaces
- Breaking: Existing invalid namespaces may fail validation (rare, can be migrated)
Testing: TestValidateNamespace cases for valid/invalid formats
P3: Create NamespacedEventBus Wrapper (2-3 days)
Why: Improve API safety; prevent accidental wildcard subscriptions
Changes:
type NamespacedEventBus struct {
bus *EventBus
namespace string // Bound to single namespace
}
func (neb *NamespacedEventBus) Subscribe() <-chan *Event {
// ALWAYS exact match, never wildcard
// Reject if pattern contains "*" or ">"
return neb.bus.Subscribe(neb.namespace)
}
func (neb *NamespacedEventBus) SubscribeWithFilter(filter *SubscriptionFilter) <-chan *Event {
return neb.bus.SubscribeWithFilter(neb.namespace, filter)
}
func (neb *NamespacedEventBus) Publish(event *Event) {
// Verify event is for this namespace (once namespace field is added)
if event.Namespace != neb.namespace {
return errors.New("event namespace mismatch")
}
neb.bus.Publish(neb.namespace, event)
}
Impact:
- Prevents: Accidental wildcard subscriptions
- Enables: Easier-to-use API for single-namespace scenarios
- Breaking: None (additive wrapper)
Testing: TestNamespacedEventBus verifies wildcards are rejected
P4: Cross-Namespace Integration Tests (1-2 days)
Why: Verify isolation at all layers (storage, local pub/sub, cross-node)
Test Cases:
- SaveEvent(store1, ns1) → GetEvents(store2, ns2) returns empty
- Publish(ns1) → Subscribe(ns2) receives nothing
- SaveEvent(store1, ns1, "prod.orders") → GetEvents(store2, "prod.users") returns empty
- Publish("prod.orders") → Subscribe("prod.*") receives it
- Publish("prod.orders") → Subscribe(">") receives it
- Cross-node: Publish on node1 → Subscribe on node2, verify namespace isolation
Impact:
- Confidence: Safety invariant is enforced at all layers
- Coverage: Currently no integration tests for namespace isolation
- Effort: 1-2 days
P5: Document Namespace Hierarchies and Patterns (1 day)
Why: Clarify intended use of dot-separated namespaces
Documentation:
- Define hierarchical namespace format:
{domain}.{environment}.{tenant} - Examples:
- "orders.prod.acme" - ACME Corp, production orders domain
- "users.staging.acme" - ACME Corp, staging users domain
- "acme.>" - All namespaces for ACME tenant
- ".prod." - All production namespaces across all domains
- Warning: Dots are sanitized to underscores at JetStream level
- "orders.prod" → stored in "orders_prod_events" stream
- Namespace isolation still works, but stream name is sanitized
Impact:
- Clarity: Applications understand how to structure namespaces
- Examples: Clear patterns for multi-tenant and multi-domain setups
Security Considerations
Wildcard Subscriptions Bypass Isolation
Intentional Design: Wildcard patterns receive events from multiple namespaces. This is necessary for:
- Cross-cutting concerns (logging, auditing, monitoring)
- Trusted system components (ops, admin code)
- Distributed tracing (observability across boundaries)
Documented In:
- eventbus.go lines 10-16 (EventBroadcaster)
- eventbus.go lines 63-66 (EventBus)
- nats_eventbus.go lines 15-20 (NATSEventBus)
- pattern.go lines 19-26 (MatchNamespacePattern)
Mitigations:
- Code Review: Audit any use of "*" or ">" in subscriptions
- Logging: NATSEventBus logs wildcard subscriptions (lines 100-101)
- Metrics: Wildcard subscriptions are tracked (RecordSubscribe)
- Documentation: Explicit warnings in all relevant locations
Risk Assessment:
- Low if: Wildcard access is restricted to trusted components (ops, admin code paths)
- High if: User-facing APIs accept subscription patterns from external clients
- High if: Feature flags control subscription patterns (default should be exact, not wildcard)
Recommended Controls:
- Restrict wildcard subscriptions to admin/ops code
- Audit logs for all wildcard subscription creation
- Code review checklist: "Does this code use wildcard subscriptions? If yes, is it justified?"
- Disable wildcard subscriptions by default in application APIs
Testing Strategy
Existing Tests (namespace_test.go, pattern_test.go)
- ✓ Stream naming with and without namespace
- ✓ Sanitization of special characters
- ✓ Pattern matching (single token, multi-token, wildcards)
- ✓ Actor type extraction
Missing Integration Tests
- ✗ SaveEvent in one namespace invisible to another namespace's GetEvents
- ✗ Publish to exact namespace not received by different exact namespace
- ✗ Publish to "prod.orders" IS received by Subscribe("prod.*")
- ✗ Cross-node namespace isolation
Recommended Test Suite
// store_namespace_isolation_test.go
func TestStorageIsolation_DifferentNamespaces(t *testing.T)
func TestStorageIsolation_SameStreamName(t *testing.T)
func TestStorageIsolation_ActorIDConflict(t *testing.T)
// eventbus_namespace_isolation_test.go
func TestEventBusExactNamespace(t *testing.T)
func TestEventBusWildcardPattern(t *testing.T)
func TestEventBusFilterWithNamespace(t *testing.T)
// nats_eventbus_namespace_isolation_integration_test.go
func TestNATSEventBusCrossNodeIsolation(t *testing.T)
func TestNATSEventBusWildcardCrossNode(t *testing.T)
Implementation Checklist
-
Phase 1: Core (0 risk)
- Add namespace field to Event struct (omitempty JSON)
- Update SaveEvent to populate event.Namespace
- Create integration tests for storage isolation
-
Phase 2: Validation (low risk)
- Create ValidateNamespace() function
- Update NewJetStreamEventStoreWithNamespace to validate
- Document namespace format conventions
-
Phase 3: Convenience (low risk)
- Create NamespacedEventBus wrapper
- Add examples for both generic and namespaced use cases
-
Phase 4: Observability (low risk)
- Document hierarchical namespace patterns
- Update examples with prod/staging/tenant scenarios
- Add to vision.md or architecture guide
-
Phase 5: Safety (no code changes)
- Add wildcard subscription warnings to API docs
- Create audit logging guide (optional implementation)
- Add security checklist to CLAUDE.md
References
Full Domain Model: DOMAIN_MODEL_NAMESPACE_ISOLATION.md
Key Code Locations:
- EventBus:
/aether/eventbus.go(268 lines) - NATSEventBus:
/aether/nats_eventbus.go(231 lines) - Pattern Matching:
/aether/pattern.go(197 lines) - JetStream Storage:
/aether/store/jetstream.go(382 lines) - Namespace Tests:
/aether/store/namespace_test.go(125 lines)
Design Philosophy:
- Vision:
/aether/vision.md("Primitives Over Frameworks") - Organization:
/aether/CLAUDE.md("Namespace Isolation" bounded context)
Conclusion
Status: Implementation Meets Domain Model (Mostly)
The Namespace Isolation context has strong primitive foundations:
- ✓ Storage-layer isolation (separate JetStream streams)
- ✓ Pub/sub isolation (exactSubscribers per namespace)
- ✓ Pattern matching (NATS-native wildcards)
- ✓ Safety warnings (documented wildcard risks)
Remaining Work:
- Add namespace metadata to events (enables audit trails, tracing)
- Add explicit validation (prevents silent sanitization)
- Improve API ergonomics (NamespacedEventBus wrapper)
- Integration tests (confidence in safety invariants)
No architectural changes needed. Refactoring is additive: improvements to observability, safety, and usability without changing core design.
The system correctly enforces the core invariant: Events in namespace X are invisible to queries from namespace Y, except through explicit wildcard subscriptions by trusted components.