Files
aether/.product-strategy/NAMESPACE_ISOLATION_ARCHITECTURE.md
Hugo Nijhuis 271f5db444
Some checks failed
CI / build (push) Successful in 21s
CI / integration (push) Failing after 2m1s
Move product strategy documentation to .product-strategy directory
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>
2026-01-12 23:57:20 +01:00

18 KiB

Namespace Isolation Architecture Diagram

System Architecture

┌─────────────────────────────────────────────────────────────────┐
│                      Application Layer                          │
│  (Defines namespace meaning, validates format, controls access) │
└──────────────┬─────────────────────────────────────────────────┘
               │
        ┌──────┴──────────────────────────────────────────┐
        │                                                  │
        v                                                  v
┌─────────────────────┐                      ┌──────────────────────┐
│  EventBus           │                      │ JetStreamEventStore  │
│  (Local Pub/Sub)    │                      │ (Persistent Store)   │
│                     │                      │                      │
│ publish(ns, event)  │                      │ saveEvent(ns, event) │
│ subscribe(pattern)  │                      │ getEvents(ns, actor) │
│                     │                      │                      │
│ exactSubscribers    │                      │ Stream:              │
│  [ns1] → [sub1]     │                      │ {namespace}_events   │
│  [ns2] → [sub2]     │                      │                      │
│ wildcardSubscribers │                      │ Subject:             │
│  [*] → [sub3]       │                      │ events.{type}.{id}   │
│  [prod.>] → [sub4]  │                      │                      │
└──────────┬──────────┘                      └──────────┬───────────┘
           │                                            │
           │ (local node)                               │ (local node)
           │                                            │
           v                                            v
    ┌──────────────────────────────────────────────────────┐
    │          NATSEventBus (Cross-Node)                   │
    │                                                      │
    │  publish(ns) → aether.events.{namespace}           │
    │  subscribe(pattern) → aether.events.{pattern}      │
    │                                                      │
    │  NATS Subjects:                                     │
    │  ├─ aether.events.tenant-abc                        │
    │  ├─ aether.events.tenant-def                        │
    │  ├─ aether.events.prod.*       (wildcard)          │
    │  └─ aether.events.>            (all)               │
    └──────────────────────────────────────────────────────┘
           │
           │ (NATS cluster)
           v
    ┌──────────────────────────┐
    │   Node 1 | Node 2 | ...  │
    │   (NATS cluster members) │
    └──────────────────────────┘

Invariant Enforcement Layers

Layer 1: EventBus (Memory Isolation)

Exact Namespace Isolation
────────────────────────
publish("tenant-a", event)
  └─> exactSubscribers["tenant-a"] → [sub1, sub2]  ✓ Receives
  └─> exactSubscribers["tenant-b"] → [sub3]        ✗ Does NOT receive
  └─> wildcardSubscribers → [sub4 ("*"), sub5 (">")]  ✓ Receive (intentional)

Implementation:
├─ exactSubscribers: map[namespace] → [subscriber]  (isolation boundary)
├─ wildcardSubscribers: [subscriber]                 (cross-boundary, trusted)
└─ publish() delivers to both groups (filter matching applied)

Layer 2: JetStreamEventStore (Storage Isolation)

Storage Namespace Isolation
──────────────────────────
Application 1: NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant-a")
  └─> Stream: "tenant_a_events"
      ├─ Subject: events.order.order-123
      └─ Subject: events.user.user-456

Application 2: NewJetStreamEventStoreWithNamespace(natsConn, "events", "tenant-b")
  └─> Stream: "tenant_b_events"
      ├─ Subject: events.order.order-789
      └─ Subject: events.user.user-012

Isolation Guarantee:
└─ GetEvents(store1, actor) → only from "tenant_a_events" stream
   GetEvents(store2, actor) → only from "tenant_b_events" stream
   (Different NATS streams = complete isolation)

Layer 3: NATS Subject Routing (Cross-Node)

NATS Subject Hierarchy
─────────────────────
aether.events.tenant-a          ← Exact subscription
aether.events.tenant-b          ← Exact subscription
aether.events.prod.orders       ← Hierarchical
aether.events.prod.users        ← Hierarchical
aether.events.staging.orders    ← Hierarchical

Pattern Matching:
├─ "tenant-a"          → aether.events.tenant-a only
├─ "*"                 → aether.events.X (one token, no dots)
├─ "prod.*"            → aether.events.prod.orders, prod.users
├─ "prod.>"            → aether.events.prod.orders, prod.users, prod.orders.legacy
└─ ">"                 → aether.events.ANYTHING (global, auditing only)

Event Flow: Publish and Subscribe

Scenario 1: Exact Namespace Isolation

Application Code:
─────────────────
bus := aether.NewEventBus()
ch1 := bus.Subscribe("tenant-a")       // Exact match: isolation enforced
ch2 := bus.Subscribe("tenant-b")       // Exact match: isolation enforced

event := Event{ActorID: "order-123", EventType: "OrderPlaced"}
bus.Publish("tenant-a", event)         // Publish to tenant-a

Result:
├─ ch1 ← event  ✓ (matched exactly)
├─ ch2 ← (nothing) ✗ (different namespace)
└─ Isolation enforced

Implementation:

func (eb *EventBus) Publish(namespaceID string, event *Event) {
    // 1. Deliver to exact subscribers
    subscribers := eb.exactSubscribers[namespaceID]  // Only this namespace
    for _, sub := range subscribers {
        eb.deliverToSubscriber(sub, event, namespaceID)
    }

    // 2. Deliver to matching wildcard subscribers
    for _, sub := range eb.wildcardSubscribers {
        if MatchNamespacePattern(sub.pattern, namespaceID) {  // Pattern matching
            eb.deliverToSubscriber(sub, event, namespaceID)
        }
    }
}

Scenario 2: Wildcard Subscription (Cross-Boundary)

Application Code (Auditing System):
────────────────────────────────────
bus := aether.NewEventBus()
chAudit := bus.Subscribe(">")           // Wildcard: receives all namespaces

bus.Publish("tenant-a", event1)         // Publish to tenant-a
bus.Publish("tenant-b", event2)         // Publish to tenant-b

Result:
├─ chAudit ← event1  ✓ (matches ">")
└─ chAudit ← event2  ✓ (matches ">")

Note: Wildcard intentionally bypasses isolation for observability
      (only granted to trusted auditing/logging/monitoring code)

Scenario 3: Cross-Node Publishing

Node 1:                           Node 2:
────────                          ────────
neb1 := NewNATSEventBus(conn1)    neb2 := NewNATSEventBus(conn2)
ch1 := neb1.Subscribe("tenant-a") ch2 := neb2.Subscribe("tenant-a")

neb1.Publish("tenant-a", event)
  │
  ├─> EventBus.Publish() [local]
  │   └─> ch1 ← event  (local delivery)
  │
  └─> NATS Publish(aether.events.tenant-a) [cross-node]
      │
      └─> [NATS Broker]
          │
          └─> Node 2: Subscription aether.events.tenant-a
              └─> ch2 ← event  (remote delivery)

Result: Both nodes receive, isolation enforced at namespace level

Pattern Matching Rules

Token-Based Matching (NATS-Native)

Namespace: "prod.orders.acme"  (3 tokens: ["prod", "orders", "acme"])

Patterns and Results:
├─ "prod.orders.acme"  → MATCH (exact)
├─ "prod.orders.*"     → MATCH ("*" matches "acme")
├─ "prod.*.acme"       → MATCH ("*" matches "orders")
├─ "prod.>"            → MATCH (">" matches "orders.acme")
├─ "prod.*"            → NO MATCH ("*" doesn't match multiple tokens)
├─ "*"                 → NO MATCH ("*" doesn't match multiple tokens)
├─ ">"                 → MATCH (">" matches everything)
└─ "prod.users.acme"   → NO MATCH (different tokens)

Rules:
├─ "." separates tokens
├─ "*" matches exactly one token (anything except ".")
├─ ">" matches one or more tokens (only at end)
├─ Exact strings match exactly
└─ Empty pattern matches nothing

Subject Sanitization

Why Sanitization Is Needed

NATS subject tokens have restrictions:

  • Cannot contain spaces: "tenant abc" ✗
  • Cannot contain dots: "tenant.abc" (becomes two tokens)
  • Cannot contain wildcards: "tenant*abc" ✗
  • Cannot contain ">": "tenant>abc" ✗

Sanitization Process

Input Namespace:        "tenant.abc"
Sanitization:           Replace . with _
Output Stream Name:     "tenant_abc_events"
NATS Subject:           "tenant_abc_events.events.order.order-123"

Security Guarantee:
└─ No way to inject NATS wildcards or special subjects via namespace

Sanitization Rules

func sanitizeSubject(s string) string {
    s = strings.ReplaceAll(s, " ", "_")   // space → underscore
    s = strings.ReplaceAll(s, ".", "_")   // dot → underscore
    s = strings.ReplaceAll(s, "*", "_")   // star → underscore
    s = strings.ReplaceAll(s, ">", "_")   // greater → underscore
    return s
}

Examples:

"tenant abc"       → "tenant_abc"
"tenant.acme"      → "tenant_acme"
"tenant*acme"      → "tenant_acme"
"tenant>acme"      → "tenant_acme"
"prod.orders.v2"   → "prod_orders_v2"

Value Objects

Namespace

type Namespace string

Characteristics:
├─ Identity: Defined by string value alone
├─ Equality: Namespace("a") == Namespace("a")
├─ Immutability: Cannot change (value type)
├─ Meaning: Application-defined (tenant, domain, environment, etc.)
└─ Format: Alphanumeric + hyphens + dots (for hierarchies)

Examples:
├─ "tenant-a"        (simple)
├─ "tenant-123"      (alphanumeric)
├─ "prod.orders"     (hierarchical: domain.environment)
├─ "prod.us.east"    (hierarchical: domain.env.region)
└─ "acme.prod.orders" (hierarchical: tenant.env.domain)

SubjectPattern

type SubjectPattern string

Characteristics:
├─ NATS-native patterns (*, >)
├─ Dot-separated tokens
├─ Wildcards bypass isolation (documented)
└─ Applied consistently via MatchNamespacePattern()

Examples:
├─ "tenant-a"       (exact: isolation enforced)
├─ "*"              (single-token wildcard)
├─ "prod.*"         (match prod.X)
├─ "prod.>"         (match prod.X.Y.Z)
└─ ">"              (match all: auditing only)

Commands and Their Effects

DefineNamespace

Input: namespace string
└─ Validates format (application responsibility)
└─ Creates logical boundary identifier

Result: Namespace can be used in:
├─ NewJetStreamEventStoreWithNamespace(conn, "events", namespace)
├─ EventBus.Subscribe(namespace)
└─ EventBus.Publish(namespace, event)

PublishToNamespace

Input: namespace string, event *Event
└─ Validates event structure

Processing:
├─ EventBus.Publish()
│  ├─ Deliver to exactSubscribers[namespace]
│  └─ Deliver to matching wildcard subscribers
└─ NATSEventBus.Publish()
   └─ Publish to NATS subject "aether.events.{namespace}"

Result: Event delivered to all subscribers matching the namespace

SubscribeToNamespace

Input: pattern string, filter *SubscriptionFilter
└─ Validates pattern format

Processing:
├─ EventBus.SubscribeWithFilter()
│  ├─ If pattern has wildcards: add to wildcardSubscribers
│  └─ Else: add to exactSubscribers[pattern]
└─ NATSEventBus.SubscribeWithFilter()
   └─ Create NATS subscription to "aether.events.{pattern}"

Result: Channel created that receives matching events
        (filtered by EventTypes and ActorPattern if provided)

Policies

Policy 1: Namespace Event Routing

Trigger: publish(namespace, event)
────────────────────────────────
Action:
1. Deliver to exactSubscribers[namespace]
2. Deliver to all wildcardSubscribers where pattern matches namespace

Filter Application:
├─ EventTypes: Event must have one of the specified types
├─ ActorPattern: Event.ActorID must match pattern
└─ Logic: AND (both must match if both specified)

Invariant Enforced:
└─ Namespace Boundary Isolation
   (events in namespace X don't leak to namespace Y,
    except through intentional wildcard subscriptions)

Policy 2: NATS Subject Namespacing

Trigger: NATSEventBus.Publish(namespace, event)
──────────────────────────────────────────────
Action: Format subject with namespace prefix
└─ subject := fmt.Sprintf("aether.events.%s", namespace)

Example:
├─ namespace = "tenant-a"
└─ subject = "aether.events.tenant-a"

Purpose:
└─ Ensures cross-node events respect namespace boundaries
   (NATS natively routes aether.events.tenant-a separately
    from aether.events.tenant-b)

Policy 3: Storage Stream Isolation

Trigger: NewJetStreamEventStoreWithNamespace(conn, streamName, namespace)
──────────────────────────────────────────────────────────────────────
Action: Create separate JetStream stream per namespace
└─ effectiveStreamName := fmt.Sprintf("%s_%s", sanitizeSubject(namespace), streamName)
   → "tenant-a_events", "tenant-b_events", etc.

Purpose:
├─ Complete storage-layer isolation (separate NATS streams)
├─ GetEvents(store1) cannot see events from GetEvents(store2)
└─ Prevents accidental data leakage even if EventBus logic fails

Invariant Enforced:
└─ Namespace Boundary Isolation (storage layer)

Failure Scenarios

Scenario 1: Attempting Cross-Namespace Read

Application Code:
─────────────────
store1 := NewJetStreamEventStoreWithNamespace(conn, "events", "tenant-a")
store2 := NewJetStreamEventStoreWithNamespace(conn, "events", "tenant-b")

store1.SaveEvent(Event{ActorID: "order-123", ...})
store2.GetEvents("order-123", 0)

Expected Result:
├─ store2 queries its own stream "tenant_b_events"
└─ Returns: empty (order-123 was not saved in tenant_b_events)

Isolation Enforced: ✓

Scenario 2: Wildcard Subscription Receives Cross-Namespace

Application Code:
─────────────────
bus := NewEventBus()
chWildcard := bus.Subscribe(">")        // CAUTION: bypass isolation!
chExact := bus.Subscribe("tenant-a")    // isolation enforced

bus.Publish("tenant-a", event1)
bus.Publish("tenant-b", event2)

Result:
├─ chExact ← event1  ✓ (only tenant-a)
├─ chWildcard ← event1  ✓ (matches ">")
├─ chWildcard ← event2  ✓ (matches ">")
└─ Isolation bypassed intentionally for auditing

Risk: chWildcard should only be used by trusted auditing code

Scenario 3: Invalid Namespace (Current Behavior)

Current Implementation:
─────────────────────
namespace := "prod.orders"  // Contains dot (invalid NATS token)
store := NewJetStreamEventStoreWithNamespace(conn, "events", namespace)

Behavior:
└─ sanitizeSubject("prod.orders") → "prod_orders"
   → Stream: "prod_orders_events" (dot replaced with underscore)

Problem: Silent transformation
└─ Developer thinks they're using "prod.orders"
└─ Storage actually uses "prod_orders"
└─ No validation error, behavior change is implicit

Future (Proposed):
──────────────────
Behavior:
└─ ValidateNamespace("prod.orders") → Error: "namespace contains invalid characters"
   → Application must fix: use "prod_orders" or define hierarchical format

Benefit: Explicit error > silent transformation

Testing Strategy

Unit Tests (Existing)

✓ pattern_test.go
  - MatchNamespacePattern() with various patterns
  - IsWildcardPattern() detection

✓ namespace_test.go
  - sanitizeSubject() character replacement
  - Stream naming with/without namespace
  - Actor type extraction

Integration Tests (Needed)

✗ store_isolation_test.go
  - SaveEvent(store1, ns1) → GetEvents(store2, ns2) returns empty
  - Different streams have completely isolated data

✗ eventbus_isolation_test.go
  - Publish(ns1) → Subscribe(ns2) receives nothing
  - Publish(ns1) → Subscribe(wildcard) receives it

✗ cross_node_isolation_test.go
  - Node1 Publish("tenant-a") → Node2 Subscribe("tenant-b") blocked
  - Node1 Publish("prod.orders") → Node2 Subscribe("prod.*") receives it

✗ pattern_matching_test.go
  - Complex hierarchical patterns verified
  - Edge cases: dots, prefixes, suffixes

Summary: Invariants and Their Enforcement

Invariant Enforced At Mechanism
Namespace Boundary Isolation EventBus, JetStream, NATS exactSubscribers per namespace; separate streams; subject routing
Namespace Name Safety JetStream SaveEvent sanitizeSubject() prevents NATS injection
Wildcard Subscriptions Bypass Isolation EventBus logic Separate wildcardSubscribers list; documented in comments
Subject Pattern Matching Consistency MatchNamespacePattern() Token-based matching with * and >

Result: Three-layer isolation (memory, storage, network) with intentional, documented exceptions for trusted components.