-
{{ rule.ruleName }}
-
{{ rule.ruleId }}
+
+
+
+ @if (rule.confidenceBand || rule.unknownConfidence !== null || rule.quiet) {
+
+ }
+
@if (rule.reason) {
{{ rule.reason }}
}
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss
index 6699fd139..cf3c58c67 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss
+++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss
@@ -1117,20 +1117,36 @@ $color-text-muted: #6b7280;
min-width: 0;
}
+ .rule-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5rem;
+ }
+
.rule-name {
- display: block;
font-weight: 500;
color: #111827;
}
.rule-id {
- display: block;
font-size: 0.75rem;
color: $color-text-muted;
background: rgba(0, 0, 0, 0.05);
padding: 0.125rem 0.25rem;
border-radius: 2px;
- margin-top: 0.25rem;
+ }
+
+ // Confidence and quiet provenance metadata (UI-POLICY-13-007)
+ .rule-metadata {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+ padding: 0.5rem;
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 4px;
}
.rule-reason {
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts
index 5566592c6..fe07bd166 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts
@@ -31,6 +31,8 @@ import {
VexStatusSummary,
} from '../../core/api/evidence.models';
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
+import { ConfidenceBadgeComponent } from '../../shared/components/confidence-badge.component';
+import { QuietProvenanceIndicatorComponent } from '../../shared/components/quiet-provenance-indicator.component';
type TabId = 'observations' | 'linkset' | 'vex' | 'policy' | 'aoc';
type ObservationView = 'side-by-side' | 'stacked';
@@ -38,7 +40,7 @@ type ObservationView = 'side-by-side' | 'stacked';
@Component({
selector: 'app-evidence-panel',
standalone: true,
- imports: [CommonModule],
+ imports: [CommonModule, ConfidenceBadgeComponent, QuietProvenanceIndicatorComponent],
templateUrl: './evidence-panel.component.html',
styleUrls: ['./evidence-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
diff --git a/src/Web/StellaOps.Web/src/app/shared/components/confidence-badge.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/confidence-badge.component.ts
new file mode 100644
index 000000000..c3991a88c
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/components/confidence-badge.component.ts
@@ -0,0 +1,250 @@
+import { Component, Input, computed, input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+/**
+ * Confidence band values matching backend PolicyUnknownConfidenceConfig.
+ */
+export type ConfidenceBand = 'high' | 'medium' | 'low' | 'unspecified';
+
+/**
+ * Confidence badge component for displaying policy confidence metadata.
+ * Shows confidence band with color coding and optional age/score details.
+ *
+ * Confidence bands:
+ * - high (≥0.65): Fresh unknowns with recent telemetry
+ * - medium (≥0.35): Unknowns aging toward action required
+ * - low (≥0.0): Stale unknowns that must be triaged
+ *
+ * @see UI-POLICY-13-007
+ */
+@Component({
+ selector: 'app-confidence-badge',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+ {{ bandLabel() }}
+ @if (showScore() && confidence() !== null) {
+ {{ formatScore() }}
+ }
+ @if (showAge() && ageDays() !== null) {
+ {{ formatAge() }}
+ }
+
+ `,
+ styles: [`
+ .confidence-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: help;
+ transition: opacity 0.15s;
+
+ &:hover {
+ opacity: 0.9;
+ }
+ }
+
+ .confidence-badge__band {
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+ }
+
+ .confidence-badge__score {
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .confidence-badge__age {
+ font-size: 0.6875rem;
+ opacity: 0.85;
+ }
+
+ // Band-specific colors
+ .confidence-badge--high {
+ background: #dcfce7;
+ color: #15803d;
+ border: 1px solid #86efac;
+ }
+
+ .confidence-badge--medium {
+ background: #fef3c7;
+ color: #92400e;
+ border: 1px solid #fcd34d;
+ }
+
+ .confidence-badge--low {
+ background: #fee2e2;
+ color: #dc2626;
+ border: 1px solid #fca5a5;
+ }
+
+ .confidence-badge--unspecified {
+ background: #f3f4f6;
+ color: #6b7280;
+ border: 1px solid #d1d5db;
+ }
+
+ // Compact variant
+ .confidence-badge--compact {
+ padding: 0.125rem 0.375rem;
+ font-size: 0.6875rem;
+
+ .confidence-badge__score,
+ .confidence-badge__age {
+ display: none;
+ }
+ }
+
+ // Expanded variant with vertical layout
+ .confidence-badge--expanded {
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 0.5rem 0.75rem;
+
+ .confidence-badge__band {
+ font-size: 0.8125rem;
+ }
+
+ .confidence-badge__score {
+ font-size: 1rem;
+ }
+
+ .confidence-badge__age {
+ font-size: 0.75rem;
+ margin-top: 0.125rem;
+ }
+ }
+ `],
+})
+export class ConfidenceBadgeComponent {
+ /**
+ * Confidence band: 'high', 'medium', 'low', or 'unspecified'.
+ */
+ readonly band = input
(null);
+
+ /**
+ * Numeric confidence score (0-1).
+ */
+ readonly confidence = input(null);
+
+ /**
+ * Age in days since unknown was first observed.
+ */
+ readonly ageDays = input(null);
+
+ /**
+ * Whether to show the numeric score.
+ */
+ readonly showScore = input(false);
+
+ /**
+ * Whether to show the age in days.
+ */
+ readonly showAge = input(false);
+
+ /**
+ * Display variant: 'default', 'compact', or 'expanded'.
+ */
+ readonly variant = input<'default' | 'compact' | 'expanded'>('default');
+
+ protected readonly badgeClass = computed(() => {
+ const b = this.normalizedBand();
+ const v = this.variant();
+ const classes = [`confidence-badge--${b}`];
+ if (v !== 'default') {
+ classes.push(`confidence-badge--${v}`);
+ }
+ return classes.join(' ');
+ });
+
+ protected readonly normalizedBand = computed((): ConfidenceBand => {
+ const b = this.band();
+ if (b === 'high' || b === 'medium' || b === 'low') {
+ return b;
+ }
+ return 'unspecified';
+ });
+
+ protected readonly bandLabel = computed(() => {
+ const b = this.normalizedBand();
+ switch (b) {
+ case 'high':
+ return 'High';
+ case 'medium':
+ return 'Medium';
+ case 'low':
+ return 'Low';
+ default:
+ return 'Unknown';
+ }
+ });
+
+ protected readonly tooltipText = computed(() => {
+ const b = this.normalizedBand();
+ const conf = this.confidence();
+ const age = this.ageDays();
+
+ let text = '';
+ switch (b) {
+ case 'high':
+ text = 'High confidence: Fresh unknown with recent telemetry';
+ break;
+ case 'medium':
+ text = 'Medium confidence: Unknown aging toward action required';
+ break;
+ case 'low':
+ text = 'Low confidence: Stale unknown that must be triaged';
+ break;
+ default:
+ text = 'Confidence not specified';
+ }
+
+ if (conf !== null) {
+ text += ` (score: ${(conf * 100).toFixed(0)}%)`;
+ }
+ if (age !== null) {
+ text += ` | Age: ${this.formatAgeFull(age)}`;
+ }
+
+ return text;
+ });
+
+ protected readonly ariaLabel = computed(() => {
+ return `Confidence: ${this.bandLabel()}`;
+ });
+
+ protected formatScore(): string {
+ const conf = this.confidence();
+ if (conf === null) return '';
+ return `${(conf * 100).toFixed(0)}%`;
+ }
+
+ protected formatAge(): string {
+ const age = this.ageDays();
+ if (age === null) return '';
+ if (age < 1) return '<1d';
+ if (age < 7) return `${Math.round(age)}d`;
+ if (age < 30) return `${Math.round(age / 7)}w`;
+ return `${Math.round(age / 30)}mo`;
+ }
+
+ private formatAgeFull(days: number): string {
+ if (days < 1) return 'less than 1 day';
+ if (days === 1) return '1 day';
+ if (days < 7) return `${Math.round(days)} days`;
+ if (days < 14) return '1 week';
+ if (days < 30) return `${Math.round(days / 7)} weeks`;
+ if (days < 60) return '1 month';
+ return `${Math.round(days / 30)} months`;
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/shared/components/index.ts b/src/Web/StellaOps.Web/src/app/shared/components/index.ts
index 7bd2c2a9c..d3dbadf93 100644
--- a/src/Web/StellaOps.Web/src/app/shared/components/index.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/components/index.ts
@@ -1,2 +1,4 @@
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
+export { ConfidenceBadgeComponent, ConfidenceBand } from './confidence-badge.component';
+export { QuietProvenanceIndicatorComponent } from './quiet-provenance-indicator.component';
diff --git a/src/Web/StellaOps.Web/src/app/shared/components/quiet-provenance-indicator.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/quiet-provenance-indicator.component.ts
new file mode 100644
index 000000000..333b989df
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/shared/components/quiet-provenance-indicator.component.ts
@@ -0,0 +1,309 @@
+import { Component, computed, input, output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+/**
+ * Quiet provenance indicator component for showing when a finding is suppressed.
+ * Displays the rule that quieted the finding with optional expand/collapse.
+ *
+ * "Quiet provenance" tracks:
+ * - quiet: boolean - Whether the finding is suppressed
+ * - quietedBy: string - Rule name that caused suppression
+ *
+ * This enables "explainably quiet by design" - suppressions with traceable justification.
+ *
+ * @see UI-POLICY-13-007
+ */
+@Component({
+ selector: 'app-quiet-provenance-indicator',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+ @if (quiet()) {
+
+
🔇
+
+ Quieted
+ @if (quietedBy()) {
+
+ by {{ quietedBy() }}
+
+ }
+
+ @if (showDetails() && quietedBy()) {
+
+ }
+
+
+ @if (showDetails() && expanded()) {
+
+
+ - Suppressed by Rule:
+ {{ quietedBy() }}
+ @if (sourceTrust()) {
+ - Source Trust:
+ - {{ sourceTrust() }}
+ }
+ @if (reachability()) {
+ - Reachability:
+ -
+
+ {{ reachabilityLabel() }}
+
+
+ }
+
+
+ }
+ } @else if (showWhenNotQuiet()) {
+
+ 🔊
+ Active
+
+ }
+ `,
+ styles: [`
+ .quiet-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.375rem 0.625rem;
+ border-radius: 6px;
+ font-size: 0.8125rem;
+ background: #f3f4f6;
+ border: 1px solid #d1d5db;
+ }
+
+ .quiet-indicator__icon {
+ font-size: 1rem;
+ }
+
+ .quiet-indicator__content {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.25rem;
+ }
+
+ .quiet-indicator__label {
+ font-weight: 600;
+ color: #374151;
+ }
+
+ .quiet-indicator__by {
+ font-size: 0.75rem;
+ color: #6b7280;
+ }
+
+ .quiet-indicator__rule {
+ background: #e5e7eb;
+ padding: 0.125rem 0.25rem;
+ border-radius: 3px;
+ font-size: 0.6875rem;
+ }
+
+ .quiet-indicator__toggle {
+ margin-left: auto;
+ padding: 0.125rem 0.375rem;
+ border: 1px solid #d1d5db;
+ border-radius: 3px;
+ background: #fff;
+ font-size: 0.6875rem;
+ color: #3b82f6;
+ cursor: pointer;
+
+ &:hover {
+ background: #eff6ff;
+ border-color: #3b82f6;
+ }
+
+ &:focus {
+ outline: 2px solid #3b82f6;
+ outline-offset: 2px;
+ }
+ }
+
+ .quiet-indicator__details {
+ margin-top: 0.5rem;
+ padding: 0.75rem;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+
+ dl {
+ margin: 0;
+ font-size: 0.8125rem;
+ }
+
+ dt {
+ color: #6b7280;
+ margin-top: 0.5rem;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ dd {
+ margin: 0.25rem 0 0;
+ color: #111827;
+
+ code {
+ background: #e5e7eb;
+ padding: 0.125rem 0.375rem;
+ border-radius: 3px;
+ font-size: 0.75rem;
+ }
+ }
+ }
+
+ .quiet-indicator__reachability {
+ display: inline-block;
+ padding: 0.125rem 0.375rem;
+ border-radius: 3px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ }
+
+ // Reachability-specific colors
+ .quiet-indicator__reachability--unreachable {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ .quiet-indicator__reachability--indirect {
+ background: #dbeafe;
+ color: #2563eb;
+ }
+
+ .quiet-indicator__reachability--direct {
+ background: #fef9c3;
+ color: #a16207;
+ }
+
+ .quiet-indicator__reachability--runtime {
+ background: #fee2e2;
+ color: #dc2626;
+ }
+
+ .quiet-indicator__reachability--entrypoint {
+ background: #fee2e2;
+ color: #dc2626;
+ }
+
+ .quiet-indicator__reachability--unknown {
+ background: #f3f4f6;
+ color: #6b7280;
+ }
+
+ // Active (not quieted) variant
+ .quiet-indicator--active {
+ background: #dbeafe;
+ border-color: #93c5fd;
+
+ .quiet-indicator__label {
+ color: #2563eb;
+ }
+ }
+
+ // Compact variant
+ .quiet-indicator--compact {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+
+ .quiet-indicator__icon {
+ font-size: 0.875rem;
+ }
+
+ .quiet-indicator__by {
+ display: none;
+ }
+ }
+ `],
+})
+export class QuietProvenanceIndicatorComponent {
+ /**
+ * Whether the finding is quieted/suppressed.
+ */
+ readonly quiet = input(false);
+
+ /**
+ * Name of the rule that quieted the finding.
+ */
+ readonly quietedBy = input(null);
+
+ /**
+ * Source trust identifier.
+ */
+ readonly sourceTrust = input(null);
+
+ /**
+ * Reachability bucket.
+ */
+ readonly reachability = input(null);
+
+ /**
+ * Whether to show the expand/collapse details toggle.
+ */
+ readonly showDetails = input(false);
+
+ /**
+ * Whether to show indicator when finding is NOT quieted.
+ */
+ readonly showWhenNotQuiet = input(false);
+
+ /**
+ * Display variant: 'default' or 'compact'.
+ */
+ readonly variant = input<'default' | 'compact'>('default');
+
+ /**
+ * Whether details are expanded.
+ */
+ readonly expanded = input(false);
+
+ /**
+ * Emitted when expand/collapse is toggled.
+ */
+ readonly expandedChange = output();
+
+ protected readonly indicatorClass = computed(() => {
+ const v = this.variant();
+ return v === 'compact' ? 'quiet-indicator--compact' : '';
+ });
+
+ protected readonly reachabilityClass = computed(() => {
+ const r = this.reachability();
+ if (!r) return 'quiet-indicator__reachability--unknown';
+ return `quiet-indicator__reachability--${r.toLowerCase()}`;
+ });
+
+ protected readonly reachabilityLabel = computed(() => {
+ const r = this.reachability();
+ if (!r) return 'Unknown';
+ switch (r.toLowerCase()) {
+ case 'unreachable':
+ return 'Unreachable';
+ case 'indirect':
+ return 'Indirect';
+ case 'direct':
+ return 'Direct';
+ case 'runtime':
+ return 'Runtime';
+ case 'entrypoint':
+ return 'Entry Point';
+ default:
+ return r;
+ }
+ });
+
+ protected onToggle(): void {
+ this.expandedChange.emit(!this.expanded());
+ }
+}
diff --git a/src/__Libraries/StellaOps.Microservice/EndpointRegistry.cs b/src/__Libraries/StellaOps.Microservice/EndpointRegistry.cs
new file mode 100644
index 000000000..c59ca4034
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/EndpointRegistry.cs
@@ -0,0 +1,75 @@
+namespace StellaOps.Microservice;
+
+///
+/// Default implementation of endpoint registry using path matchers.
+///
+public sealed class EndpointRegistry : IEndpointRegistry
+{
+ private readonly List _endpoints = [];
+ private readonly bool _caseInsensitive;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Whether path matching should be case-insensitive.
+ public EndpointRegistry(bool caseInsensitive = true)
+ {
+ _caseInsensitive = caseInsensitive;
+ }
+
+ ///
+ /// Registers an endpoint descriptor.
+ ///
+ /// The endpoint descriptor to register.
+ public void Register(EndpointDescriptor endpoint)
+ {
+ var matcher = new PathMatcher(endpoint.Path, _caseInsensitive);
+ _endpoints.Add(new RegisteredEndpoint(endpoint, matcher));
+ }
+
+ ///
+ /// Registers multiple endpoint descriptors.
+ ///
+ /// The endpoint descriptors to register.
+ public void RegisterAll(IEnumerable endpoints)
+ {
+ foreach (var endpoint in endpoints)
+ {
+ Register(endpoint);
+ }
+ }
+
+ ///
+ public bool TryMatch(string method, string path, out EndpointMatch? match)
+ {
+ match = null;
+
+ foreach (var registered in _endpoints)
+ {
+ // Check method match (case-insensitive)
+ if (!string.Equals(registered.Endpoint.Method, method, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ // Check path match
+ if (registered.Matcher.TryMatch(path, out var parameters))
+ {
+ match = new EndpointMatch
+ {
+ Endpoint = registered.Endpoint,
+ PathParameters = parameters
+ };
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ public IReadOnlyList GetAllEndpoints()
+ {
+ return _endpoints.Select(e => e.Endpoint).ToList();
+ }
+
+ private sealed record RegisteredEndpoint(EndpointDescriptor Endpoint, PathMatcher Matcher);
+}
diff --git a/src/__Libraries/StellaOps.Microservice/HeaderCollection.cs b/src/__Libraries/StellaOps.Microservice/HeaderCollection.cs
new file mode 100644
index 000000000..dc9d5bbd0
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/HeaderCollection.cs
@@ -0,0 +1,102 @@
+using System.Collections;
+
+namespace StellaOps.Microservice;
+
+///
+/// Default implementation of header collection.
+///
+public sealed class HeaderCollection : IHeaderCollection
+{
+ private readonly Dictionary> _headers;
+
+ ///
+ /// Gets an empty header collection.
+ ///
+ public static readonly HeaderCollection Empty = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public HeaderCollection()
+ {
+ _headers = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Initializes a new instance from key-value pairs.
+ ///
+ public HeaderCollection(IEnumerable> headers)
+ : this()
+ {
+ foreach (var kvp in headers)
+ {
+ Add(kvp.Key, kvp.Value);
+ }
+ }
+
+ ///
+ public string? this[string key]
+ {
+ get => _headers.TryGetValue(key, out var values) && values.Count > 0 ? values[0] : null;
+ }
+
+ ///
+ /// Adds a header value.
+ ///
+ /// The header key.
+ /// The header value.
+ public void Add(string key, string value)
+ {
+ if (!_headers.TryGetValue(key, out var values))
+ {
+ values = [];
+ _headers[key] = values;
+ }
+ values.Add(value);
+ }
+
+ ///
+ /// Sets a header, replacing any existing values.
+ ///
+ /// The header key.
+ /// The header value.
+ public void Set(string key, string value)
+ {
+ _headers[key] = [value];
+ }
+
+ ///
+ public IEnumerable GetValues(string key)
+ {
+ return _headers.TryGetValue(key, out var values) ? values : [];
+ }
+
+ ///
+ public bool TryGetValue(string key, out string? value)
+ {
+ if (_headers.TryGetValue(key, out var values) && values.Count > 0)
+ {
+ value = values[0];
+ return true;
+ }
+ value = null;
+ return false;
+ }
+
+ ///
+ public bool ContainsKey(string key) => _headers.ContainsKey(key);
+
+ ///
+ public IEnumerator> GetEnumerator()
+ {
+ foreach (var kvp in _headers)
+ {
+ foreach (var value in kvp.Value)
+ {
+ yield return new KeyValuePair(kvp.Key, value);
+ }
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/src/__Libraries/StellaOps.Microservice/IEndpointDiscoveryProvider.cs b/src/__Libraries/StellaOps.Microservice/IEndpointDiscoveryProvider.cs
new file mode 100644
index 000000000..de14658e9
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/IEndpointDiscoveryProvider.cs
@@ -0,0 +1,15 @@
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Microservice;
+
+///
+/// Provides endpoint discovery functionality.
+///
+public interface IEndpointDiscoveryProvider
+{
+ ///
+ /// Discovers all endpoints in the application.
+ ///
+ /// The discovered endpoints.
+ IReadOnlyList DiscoverEndpoints();
+}
diff --git a/src/__Libraries/StellaOps.Microservice/IEndpointRegistry.cs b/src/__Libraries/StellaOps.Microservice/IEndpointRegistry.cs
new file mode 100644
index 000000000..4b3a3e8d1
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/IEndpointRegistry.cs
@@ -0,0 +1,38 @@
+namespace StellaOps.Microservice;
+
+///
+/// Registry for looking up endpoint handlers by method and path.
+///
+public interface IEndpointRegistry
+{
+ ///
+ /// Tries to find a matching endpoint for the given method and path.
+ ///
+ /// The HTTP method.
+ /// The request path.
+ /// The matching endpoint information if found.
+ /// True if a matching endpoint was found.
+ bool TryMatch(string method, string path, out EndpointMatch? match);
+
+ ///
+ /// Gets all registered endpoints.
+ ///
+ /// All registered endpoint descriptors.
+ IReadOnlyList GetAllEndpoints();
+}
+
+///
+/// Represents a matched endpoint with extracted path parameters.
+///
+public sealed class EndpointMatch
+{
+ ///
+ /// Gets the matched endpoint descriptor.
+ ///
+ public required EndpointDescriptor Endpoint { get; init; }
+
+ ///
+ /// Gets the path parameters extracted from the URL.
+ ///
+ public required IReadOnlyDictionary PathParameters { get; init; }
+}
diff --git a/src/__Libraries/StellaOps.Microservice/IHeaderCollection.cs b/src/__Libraries/StellaOps.Microservice/IHeaderCollection.cs
new file mode 100644
index 000000000..1d5acf06e
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/IHeaderCollection.cs
@@ -0,0 +1,36 @@
+namespace StellaOps.Microservice;
+
+///
+/// Abstraction for HTTP-style header collection.
+///
+public interface IHeaderCollection : IEnumerable>
+{
+ ///
+ /// Gets a header value by key.
+ ///
+ /// The header key (case-insensitive).
+ /// The header value, or null if not found.
+ string? this[string key] { get; }
+
+ ///
+ /// Gets all values for a header key.
+ ///
+ /// The header key (case-insensitive).
+ /// All values for the key.
+ IEnumerable GetValues(string key);
+
+ ///
+ /// Tries to get a header value.
+ ///
+ /// The header key.
+ /// The header value if found.
+ /// True if the header was found.
+ bool TryGetValue(string key, out string? value);
+
+ ///
+ /// Checks if a header exists.
+ ///
+ /// The header key.
+ /// True if the header exists.
+ bool ContainsKey(string key);
+}
diff --git a/src/__Libraries/StellaOps.Microservice/IRouterConnectionManager.cs b/src/__Libraries/StellaOps.Microservice/IRouterConnectionManager.cs
new file mode 100644
index 000000000..c921ea949
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/IRouterConnectionManager.cs
@@ -0,0 +1,26 @@
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Microservice;
+
+///
+/// Manages connections to router gateways.
+///
+public interface IRouterConnectionManager
+{
+ ///
+ /// Gets the current connection states.
+ ///
+ IReadOnlyList Connections { get; }
+
+ ///
+ /// Starts the connection manager.
+ ///
+ /// Cancellation token.
+ Task StartAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Stops the connection manager.
+ ///
+ /// Cancellation token.
+ Task StopAsync(CancellationToken cancellationToken);
+}
diff --git a/src/__Libraries/StellaOps.Microservice/IStellaEndpoint.cs b/src/__Libraries/StellaOps.Microservice/IStellaEndpoint.cs
new file mode 100644
index 000000000..7021da51e
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/IStellaEndpoint.cs
@@ -0,0 +1,52 @@
+namespace StellaOps.Microservice;
+
+///
+/// Marker interface for all Stella endpoints.
+///
+public interface IStellaEndpoint
+{
+}
+
+///
+/// Interface for a typed Stella endpoint with request and response.
+///
+/// The request type.
+/// The response type.
+public interface IStellaEndpoint : IStellaEndpoint
+{
+ ///
+ /// Handles the request.
+ ///
+ /// The request.
+ /// Cancellation token.
+ /// The response.
+ Task HandleAsync(TRequest request, CancellationToken cancellationToken);
+}
+
+///
+/// Interface for a typed Stella endpoint with response only (no request body).
+///
+/// The response type.
+public interface IStellaEndpoint : IStellaEndpoint
+{
+ ///
+ /// Handles the request.
+ ///
+ /// Cancellation token.
+ /// The response.
+ Task HandleAsync(CancellationToken cancellationToken);
+}
+
+///
+/// Interface for a raw Stella endpoint that handles requests with full context.
+///
+public interface IRawStellaEndpoint : IStellaEndpoint
+{
+ ///
+ /// Handles the raw request with full context.
+ ///
+ /// The request context including headers, path parameters, and body stream.
+ /// Cancellation token.
+ /// The raw response including status code, headers, and body stream.
+ Task HandleAsync(RawRequestContext context, CancellationToken cancellationToken);
+}
diff --git a/src/__Libraries/StellaOps.Microservice/MicroserviceHostedService.cs b/src/__Libraries/StellaOps.Microservice/MicroserviceHostedService.cs
new file mode 100644
index 000000000..8311daeee
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/MicroserviceHostedService.cs
@@ -0,0 +1,40 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace StellaOps.Microservice;
+
+///
+/// Hosted service that manages the microservice lifecycle.
+///
+public sealed class MicroserviceHostedService : IHostedService
+{
+ private readonly IRouterConnectionManager _connectionManager;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MicroserviceHostedService(
+ IRouterConnectionManager connectionManager,
+ ILogger logger)
+ {
+ _connectionManager = connectionManager;
+ _logger = logger;
+ }
+
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Starting Stella microservice");
+ await _connectionManager.StartAsync(cancellationToken);
+ _logger.LogInformation("Stella microservice started");
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Stopping Stella microservice");
+ await _connectionManager.StopAsync(cancellationToken);
+ _logger.LogInformation("Stella microservice stopped");
+ }
+}
diff --git a/src/__Libraries/StellaOps.Microservice/PathMatcher.cs b/src/__Libraries/StellaOps.Microservice/PathMatcher.cs
new file mode 100644
index 000000000..565557347
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/PathMatcher.cs
@@ -0,0 +1,85 @@
+using System.Text.RegularExpressions;
+
+namespace StellaOps.Microservice;
+
+///
+/// Matches request paths against route templates.
+///
+public sealed partial class PathMatcher
+{
+ private readonly string _template;
+ private readonly Regex _regex;
+ private readonly string[] _parameterNames;
+ private readonly bool _caseInsensitive;
+
+ ///
+ /// Gets the route template.
+ ///
+ public string Template => _template;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The route template (e.g., "/api/users/{id}").
+ /// Whether matching should be case-insensitive.
+ public PathMatcher(string template, bool caseInsensitive = true)
+ {
+ _template = template;
+ _caseInsensitive = caseInsensitive;
+
+ // Extract parameter names and build regex
+ var paramNames = new List();
+ var pattern = "^" + ParameterRegex().Replace(template, match =>
+ {
+ paramNames.Add(match.Groups[1].Value);
+ return "([^/]+)";
+ }) + "/?$";
+
+ var options = caseInsensitive ? RegexOptions.IgnoreCase : RegexOptions.None;
+ _regex = new Regex(pattern, options | RegexOptions.Compiled);
+ _parameterNames = [.. paramNames];
+ }
+
+ ///
+ /// Tries to match a path against the template.
+ ///
+ /// The request path.
+ /// The extracted path parameters if matched.
+ /// True if the path matches.
+ public bool TryMatch(string path, out Dictionary parameters)
+ {
+ parameters = [];
+
+ // Normalize path
+ path = path.TrimEnd('/');
+ if (!path.StartsWith('/'))
+ path = "/" + path;
+
+ var match = _regex.Match(path);
+ if (!match.Success)
+ return false;
+
+ for (int i = 0; i < _parameterNames.Length; i++)
+ {
+ parameters[_parameterNames[i]] = match.Groups[i + 1].Value;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Checks if a path matches the template.
+ ///
+ /// The request path.
+ /// True if the path matches.
+ public bool IsMatch(string path)
+ {
+ path = path.TrimEnd('/');
+ if (!path.StartsWith('/'))
+ path = "/" + path;
+ return _regex.IsMatch(path);
+ }
+
+ [GeneratedRegex(@"\{([^}:]+)(?::[^}]+)?\}")]
+ private static partial Regex ParameterRegex();
+}
diff --git a/src/__Libraries/StellaOps.Microservice/RawRequestContext.cs b/src/__Libraries/StellaOps.Microservice/RawRequestContext.cs
new file mode 100644
index 000000000..55e372a67
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/RawRequestContext.cs
@@ -0,0 +1,43 @@
+namespace StellaOps.Microservice;
+
+///
+/// Context for a raw request.
+///
+public sealed class RawRequestContext
+{
+ ///
+ /// Gets the HTTP method.
+ ///
+ public string Method { get; init; } = string.Empty;
+
+ ///
+ /// Gets the request path.
+ ///
+ public string Path { get; init; } = string.Empty;
+
+ ///
+ /// Gets the path parameters extracted from route templates.
+ ///
+ public IReadOnlyDictionary PathParameters { get; init; }
+ = new Dictionary();
+
+ ///
+ /// Gets the request headers.
+ ///
+ public IHeaderCollection Headers { get; init; } = HeaderCollection.Empty;
+
+ ///
+ /// Gets the request body stream.
+ ///
+ public Stream Body { get; init; } = Stream.Null;
+
+ ///
+ /// Gets the cancellation token.
+ ///
+ public CancellationToken CancellationToken { get; init; }
+
+ ///
+ /// Gets the correlation ID for request tracking.
+ ///
+ public string? CorrelationId { get; init; }
+}
diff --git a/src/__Libraries/StellaOps.Microservice/RawResponse.cs b/src/__Libraries/StellaOps.Microservice/RawResponse.cs
new file mode 100644
index 000000000..a39bd7a83
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/RawResponse.cs
@@ -0,0 +1,77 @@
+using System.Text;
+
+namespace StellaOps.Microservice;
+
+///
+/// Represents a raw response from an endpoint.
+///
+public sealed class RawResponse
+{
+ ///
+ /// Gets or sets the HTTP status code.
+ ///
+ public int StatusCode { get; init; } = 200;
+
+ ///
+ /// Gets or sets the response headers.
+ ///
+ public IHeaderCollection Headers { get; init; } = HeaderCollection.Empty;
+
+ ///
+ /// Gets or sets the response body stream.
+ ///
+ public Stream Body { get; init; } = Stream.Null;
+
+ ///
+ /// Creates a 200 OK response with a body.
+ ///
+ public static RawResponse Ok(Stream body) => new() { StatusCode = 200, Body = body };
+
+ ///
+ /// Creates a 200 OK response with a byte array body.
+ ///
+ public static RawResponse Ok(byte[] body) => new() { StatusCode = 200, Body = new MemoryStream(body) };
+
+ ///
+ /// Creates a 200 OK response with a string body.
+ ///
+ public static RawResponse Ok(string body) => Ok(Encoding.UTF8.GetBytes(body));
+
+ ///
+ /// Creates a 204 No Content response.
+ ///
+ public static RawResponse NoContent() => new() { StatusCode = 204 };
+
+ ///
+ /// Creates a 400 Bad Request response.
+ ///
+ public static RawResponse BadRequest(string? message = null) =>
+ Error(400, message ?? "Bad Request");
+
+ ///
+ /// Creates a 404 Not Found response.
+ ///
+ public static RawResponse NotFound(string? message = null) =>
+ Error(404, message ?? "Not Found");
+
+ ///
+ /// Creates a 500 Internal Server Error response.
+ ///
+ public static RawResponse InternalError(string? message = null) =>
+ Error(500, message ?? "Internal Server Error");
+
+ ///
+ /// Creates an error response with a message body.
+ ///
+ public static RawResponse Error(int statusCode, string message)
+ {
+ var headers = new HeaderCollection();
+ headers.Set("Content-Type", "text/plain; charset=utf-8");
+ return new RawResponse
+ {
+ StatusCode = statusCode,
+ Headers = headers,
+ Body = new MemoryStream(Encoding.UTF8.GetBytes(message))
+ };
+ }
+}
diff --git a/src/__Libraries/StellaOps.Microservice/ReflectionEndpointDiscoveryProvider.cs b/src/__Libraries/StellaOps.Microservice/ReflectionEndpointDiscoveryProvider.cs
new file mode 100644
index 000000000..d8c78c8e4
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/ReflectionEndpointDiscoveryProvider.cs
@@ -0,0 +1,71 @@
+using System.Reflection;
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Microservice;
+
+///
+/// Discovers endpoints using runtime reflection.
+///
+public sealed class ReflectionEndpointDiscoveryProvider : IEndpointDiscoveryProvider
+{
+ private readonly StellaMicroserviceOptions _options;
+ private readonly IEnumerable _assemblies;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The microservice options.
+ /// The assemblies to scan for endpoints.
+ public ReflectionEndpointDiscoveryProvider(StellaMicroserviceOptions options, IEnumerable? assemblies = null)
+ {
+ _options = options;
+ _assemblies = assemblies ?? AppDomain.CurrentDomain.GetAssemblies();
+ }
+
+ ///
+ public IReadOnlyList DiscoverEndpoints()
+ {
+ var endpoints = new List();
+
+ foreach (var assembly in _assemblies)
+ {
+ try
+ {
+ foreach (var type in assembly.GetTypes())
+ {
+ var attribute = type.GetCustomAttribute();
+ if (attribute is null) continue;
+
+ if (!typeof(IStellaEndpoint).IsAssignableFrom(type))
+ {
+ throw new InvalidOperationException(
+ $"Type {type.FullName} has [StellaEndpoint] but does not implement IStellaEndpoint.");
+ }
+
+ var claims = attribute.RequiredClaims
+ .Select(c => new ClaimRequirement { Type = c })
+ .ToList();
+
+ var descriptor = new EndpointDescriptor
+ {
+ ServiceName = _options.ServiceName,
+ Version = _options.Version,
+ Method = attribute.Method,
+ Path = attribute.Path,
+ DefaultTimeout = TimeSpan.FromSeconds(attribute.TimeoutSeconds),
+ SupportsStreaming = attribute.SupportsStreaming,
+ RequiringClaims = claims
+ };
+
+ endpoints.Add(descriptor);
+ }
+ }
+ catch (ReflectionTypeLoadException)
+ {
+ // Skip assemblies that cannot be loaded
+ }
+ }
+
+ return endpoints;
+ }
+}
diff --git a/src/__Libraries/StellaOps.Microservice/RouterConnectionManager.cs b/src/__Libraries/StellaOps.Microservice/RouterConnectionManager.cs
new file mode 100644
index 000000000..94a5dc4e5
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/RouterConnectionManager.cs
@@ -0,0 +1,219 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Router.Common.Abstractions;
+using StellaOps.Router.Common.Enums;
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Microservice;
+
+///
+/// Manages connections to router gateways.
+///
+public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposable
+{
+ private readonly StellaMicroserviceOptions _options;
+ private readonly IEndpointDiscoveryProvider _endpointDiscovery;
+ private readonly ITransportClient _transportClient;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _connections = new();
+ private readonly CancellationTokenSource _cts = new();
+ private IReadOnlyList? _endpoints;
+ private Task? _heartbeatTask;
+ private bool _disposed;
+
+ ///
+ public IReadOnlyList Connections => [.. _connections.Values];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RouterConnectionManager(
+ IOptions options,
+ IEndpointDiscoveryProvider endpointDiscovery,
+ ITransportClient transportClient,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _endpointDiscovery = endpointDiscovery;
+ _transportClient = transportClient;
+ _logger = logger;
+ }
+
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ _options.Validate();
+
+ _logger.LogInformation(
+ "Starting router connection manager for {ServiceName}/{Version}",
+ _options.ServiceName,
+ _options.Version);
+
+ // Discover endpoints
+ _endpoints = _endpointDiscovery.DiscoverEndpoints();
+ _logger.LogInformation("Discovered {EndpointCount} endpoints", _endpoints.Count);
+
+ // Connect to each router
+ foreach (var router in _options.Routers)
+ {
+ await ConnectToRouterAsync(router, cancellationToken);
+ }
+
+ // Start heartbeat task
+ _heartbeatTask = Task.Run(() => HeartbeatLoopAsync(_cts.Token), CancellationToken.None);
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Stopping router connection manager");
+
+ await _cts.CancelAsync();
+
+ if (_heartbeatTask is not null)
+ {
+ try
+ {
+ await _heartbeatTask.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected
+ }
+ }
+
+ _connections.Clear();
+ }
+
+ private async Task ConnectToRouterAsync(RouterEndpointConfig router, CancellationToken cancellationToken)
+ {
+ var connectionId = $"{router.Host}:{router.Port}";
+ var backoff = _options.ReconnectBackoffInitial;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ _logger.LogInformation(
+ "Connecting to router at {Host}:{Port} via {Transport}",
+ router.Host,
+ router.Port,
+ router.TransportType);
+
+ // Create connection state
+ var instance = new InstanceDescriptor
+ {
+ InstanceId = _options.InstanceId,
+ ServiceName = _options.ServiceName,
+ Version = _options.Version,
+ Region = _options.Region
+ };
+
+ var state = new ConnectionState
+ {
+ ConnectionId = connectionId,
+ Instance = instance,
+ Status = InstanceHealthStatus.Healthy,
+ LastHeartbeatUtc = DateTime.UtcNow,
+ TransportType = router.TransportType
+ };
+
+ // Register endpoints
+ foreach (var endpoint in _endpoints ?? [])
+ {
+ state.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
+ }
+
+ _connections[connectionId] = state;
+
+ // For InMemory transport, connectivity is handled via the transport client
+ // Real transports will establish actual network connections here
+
+ _logger.LogInformation(
+ "Connected to router at {Host}:{Port}, registered {EndpointCount} endpoints",
+ router.Host,
+ router.Port,
+ _endpoints?.Count ?? 0);
+
+ // Reset backoff on successful connection
+ backoff = _options.ReconnectBackoffInitial;
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Failed to connect to router at {Host}:{Port}, retrying in {Backoff}",
+ router.Host,
+ router.Port,
+ backoff);
+
+ await Task.Delay(backoff, cancellationToken);
+
+ // Exponential backoff
+ backoff = TimeSpan.FromTicks(Math.Min(
+ backoff.Ticks * 2,
+ _options.ReconnectBackoffMax.Ticks));
+ }
+ }
+ }
+
+ private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_options.HeartbeatInterval, cancellationToken);
+
+ foreach (var connection in _connections.Values)
+ {
+ try
+ {
+ // Build heartbeat payload
+ var heartbeat = new HeartbeatPayload
+ {
+ InstanceId = _options.InstanceId,
+ Status = connection.Status,
+ TimestampUtc = DateTime.UtcNow
+ };
+
+ // Update last heartbeat time
+ connection.LastHeartbeatUtc = DateTime.UtcNow;
+
+ _logger.LogDebug(
+ "Sent heartbeat for connection {ConnectionId}",
+ connection.ConnectionId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Failed to send heartbeat for connection {ConnectionId}",
+ connection.ConnectionId);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error in heartbeat loop");
+ }
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ _cts.Cancel();
+ _cts.Dispose();
+ }
+}
diff --git a/src/__Libraries/StellaOps.Microservice/RouterEndpointConfig.cs b/src/__Libraries/StellaOps.Microservice/RouterEndpointConfig.cs
index aab0802f6..479dc2784 100644
--- a/src/__Libraries/StellaOps.Microservice/RouterEndpointConfig.cs
+++ b/src/__Libraries/StellaOps.Microservice/RouterEndpointConfig.cs
@@ -1,4 +1,4 @@
-using StellaOps.Router.Common;
+using StellaOps.Router.Common.Enums;
namespace StellaOps.Microservice;
diff --git a/src/__Libraries/StellaOps.Microservice/ServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Microservice/ServiceCollectionExtensions.cs
index 42e76932a..69bdf23ab 100644
--- a/src/__Libraries/StellaOps.Microservice/ServiceCollectionExtensions.cs
+++ b/src/__Libraries/StellaOps.Microservice/ServiceCollectionExtensions.cs
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
namespace StellaOps.Microservice;
@@ -20,9 +22,53 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
- // Stub implementation - will be filled in later sprints
+ // Configure options
services.Configure(configure);
+ // Register endpoint discovery
+ services.TryAddSingleton(sp =>
+ {
+ var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
+ configure(options);
+ return new ReflectionEndpointDiscoveryProvider(options);
+ });
+
+ // Register connection manager
+ services.TryAddSingleton();
+
+ // Register hosted service
+ services.AddHostedService();
+
+ return services;
+ }
+
+ ///
+ /// Adds Stella microservice services with a custom endpoint discovery provider.
+ ///
+ /// The endpoint discovery provider type.
+ /// The service collection.
+ /// Action to configure the microservice options.
+ /// The service collection for chaining.
+ public static IServiceCollection AddStellaMicroservice(
+ this IServiceCollection services,
+ Action configure)
+ where TDiscovery : class, IEndpointDiscoveryProvider
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ // Configure options
+ services.Configure(configure);
+
+ // Register custom endpoint discovery
+ services.TryAddSingleton();
+
+ // Register connection manager
+ services.TryAddSingleton();
+
+ // Register hosted service
+ services.AddHostedService();
+
return services;
}
}
diff --git a/src/__Libraries/StellaOps.Microservice/StellaEndpointAttribute.cs b/src/__Libraries/StellaOps.Microservice/StellaEndpointAttribute.cs
new file mode 100644
index 000000000..8ba410c7e
--- /dev/null
+++ b/src/__Libraries/StellaOps.Microservice/StellaEndpointAttribute.cs
@@ -0,0 +1,46 @@
+namespace StellaOps.Microservice;
+
+///
+/// Marks a class as a Stella endpoint handler.
+///
+[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
+public sealed class StellaEndpointAttribute : Attribute
+{
+ ///
+ /// Gets the HTTP method for this endpoint.
+ ///
+ public string Method { get; }
+
+ ///
+ /// Gets the path for this endpoint.
+ ///
+ public string Path { get; }
+
+ ///
+ /// Gets or sets whether this endpoint supports streaming.
+ /// Default: false.
+ ///
+ public bool SupportsStreaming { get; set; }
+
+ ///
+ /// Gets or sets the default timeout in seconds.
+ /// Default: 30 seconds.
+ ///
+ public int TimeoutSeconds { get; set; } = 30;
+
+ ///
+ /// Gets or sets the required claim types for this endpoint.
+ ///
+ public string[] RequiredClaims { get; set; } = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP method.
+ /// The endpoint path.
+ public StellaEndpointAttribute(string method, string path)
+ {
+ Method = method?.ToUpperInvariant() ?? throw new ArgumentNullException(nameof(method));
+ Path = path ?? throw new ArgumentNullException(nameof(path));
+ }
+}
diff --git a/src/__Libraries/StellaOps.Microservice/StellaMicroserviceOptions.cs b/src/__Libraries/StellaOps.Microservice/StellaMicroserviceOptions.cs
index 1099d06d3..b6a0fcea8 100644
--- a/src/__Libraries/StellaOps.Microservice/StellaMicroserviceOptions.cs
+++ b/src/__Libraries/StellaOps.Microservice/StellaMicroserviceOptions.cs
@@ -1,11 +1,11 @@
-using StellaOps.Router.Common;
+using System.Text.RegularExpressions;
namespace StellaOps.Microservice;
///
/// Options for configuring a Stella microservice.
///
-public sealed class StellaMicroserviceOptions
+public sealed partial class StellaMicroserviceOptions
{
///
/// Gets or sets the service name.
@@ -14,6 +14,7 @@ public sealed class StellaMicroserviceOptions
///
/// Gets or sets the semantic version.
+ /// Must be valid semver (e.g., "1.0.0", "2.1.0-beta.1").
///
public required string Version { get; set; }
@@ -24,6 +25,7 @@ public sealed class StellaMicroserviceOptions
///
/// Gets or sets the unique instance identifier.
+ /// Auto-generated if not provided.
///
public string InstanceId { get; set; } = Guid.NewGuid().ToString("N");
@@ -36,5 +38,55 @@ public sealed class StellaMicroserviceOptions
///
/// Gets or sets the optional path to a YAML config file for endpoint overrides.
///
- public string? EndpointConfigPath { get; set; }
+ public string? ConfigFilePath { get; set; }
+
+ ///
+ /// Gets or sets the heartbeat interval.
+ /// Default: 10 seconds.
+ ///
+ public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Gets or sets the maximum reconnect backoff.
+ /// Default: 1 minute.
+ ///
+ public TimeSpan ReconnectBackoffMax { get; set; } = TimeSpan.FromMinutes(1);
+
+ ///
+ /// Gets or sets the initial reconnect delay.
+ /// Default: 1 second.
+ ///
+ public TimeSpan ReconnectBackoffInitial { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Validates the options and throws if invalid.
+ ///
+ public void Validate()
+ {
+ if (string.IsNullOrWhiteSpace(ServiceName))
+ throw new InvalidOperationException("ServiceName is required.");
+
+ if (string.IsNullOrWhiteSpace(Version))
+ throw new InvalidOperationException("Version is required.");
+
+ if (!SemverRegex().IsMatch(Version))
+ throw new InvalidOperationException($"Version '{Version}' is not valid semver.");
+
+ if (string.IsNullOrWhiteSpace(Region))
+ throw new InvalidOperationException("Region is required.");
+
+ if (Routers.Count == 0)
+ throw new InvalidOperationException("At least one router endpoint is required.");
+
+ foreach (var router in Routers)
+ {
+ if (string.IsNullOrWhiteSpace(router.Host))
+ throw new InvalidOperationException("Router host is required.");
+ if (router.Port <= 0 || router.Port > 65535)
+ throw new InvalidOperationException($"Router port {router.Port} is invalid.");
+ }
+ }
+
+ [GeneratedRegex(@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$")]
+ private static partial Regex SemverRegex();
}
diff --git a/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj b/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj
index e2566ab43..8b4f9d4b5 100644
--- a/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj
+++ b/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj
@@ -8,6 +8,8 @@
+
+
diff --git a/src/__Libraries/StellaOps.Router.Common/IGlobalRoutingState.cs b/src/__Libraries/StellaOps.Router.Common/Abstractions/IGlobalRoutingState.cs
similarity index 64%
rename from src/__Libraries/StellaOps.Router.Common/IGlobalRoutingState.cs
rename to src/__Libraries/StellaOps.Router.Common/Abstractions/IGlobalRoutingState.cs
index a1584dafa..f91b02900 100644
--- a/src/__Libraries/StellaOps.Router.Common/IGlobalRoutingState.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Abstractions/IGlobalRoutingState.cs
@@ -1,4 +1,6 @@
-namespace StellaOps.Router.Common;
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Router.Common.Abstractions;
///
/// Provides global routing state derived from all live connections.
@@ -21,21 +23,9 @@ public interface IGlobalRoutingState
/// The HTTP method.
/// The request path.
/// The available connection states.
- IEnumerable GetConnectionsForEndpoint(
+ IReadOnlyList GetConnectionsFor(
string serviceName,
string version,
string method,
string path);
-
- ///
- /// Registers a connection and its endpoints.
- ///
- /// The connection state to register.
- void RegisterConnection(ConnectionState connection);
-
- ///
- /// Removes a connection from the routing state.
- ///
- /// The connection ID to remove.
- void UnregisterConnection(string connectionId);
}
diff --git a/src/__Libraries/StellaOps.Router.Common/Abstractions/IRegionProvider.cs b/src/__Libraries/StellaOps.Router.Common/Abstractions/IRegionProvider.cs
new file mode 100644
index 000000000..552cfd31d
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Abstractions/IRegionProvider.cs
@@ -0,0 +1,17 @@
+namespace StellaOps.Router.Common.Abstractions;
+
+///
+/// Provides region information for routing decisions.
+///
+public interface IRegionProvider
+{
+ ///
+ /// Gets the current gateway region.
+ ///
+ string Region { get; }
+
+ ///
+ /// Gets the neighbor regions in order of preference.
+ ///
+ IReadOnlyList NeighborRegions { get; }
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/Abstractions/IRoutingPlugin.cs b/src/__Libraries/StellaOps.Router.Common/Abstractions/IRoutingPlugin.cs
new file mode 100644
index 000000000..0f81633f5
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Abstractions/IRoutingPlugin.cs
@@ -0,0 +1,19 @@
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Router.Common.Abstractions;
+
+///
+/// Provides extensibility for routing decisions.
+///
+public interface IRoutingPlugin
+{
+ ///
+ /// Chooses an instance for the routing context.
+ ///
+ /// The routing context.
+ /// Cancellation token.
+ /// The routing decision, or null if this plugin cannot decide.
+ Task ChooseInstanceAsync(
+ RoutingContext context,
+ CancellationToken cancellationToken);
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/Abstractions/ITransportClient.cs b/src/__Libraries/StellaOps.Router.Common/Abstractions/ITransportClient.cs
new file mode 100644
index 000000000..4091cd0e6
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Abstractions/ITransportClient.cs
@@ -0,0 +1,51 @@
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Router.Common.Abstractions;
+
+///
+/// Represents a transport client for sending requests to microservices.
+///
+public interface ITransportClient
+{
+ ///
+ /// Sends a request and waits for a response.
+ ///
+ /// The connection to use.
+ /// The request frame.
+ /// The timeout for the request.
+ /// Cancellation token.
+ /// The response frame.
+ Task SendRequestAsync(
+ ConnectionState connection,
+ Frame requestFrame,
+ TimeSpan timeout,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Sends a cancellation request.
+ ///
+ /// The connection to use.
+ /// The correlation ID of the request to cancel.
+ /// Optional reason for cancellation.
+ Task SendCancelAsync(
+ ConnectionState connection,
+ Guid correlationId,
+ string? reason = null);
+
+ ///
+ /// Sends a streaming request and processes the streaming response.
+ ///
+ /// The connection to use.
+ /// The request header frame.
+ /// The request body stream.
+ /// Callback to read the response body stream.
+ /// Payload limits to enforce.
+ /// Cancellation token.
+ Task SendStreamingAsync(
+ ConnectionState connection,
+ Frame requestHeader,
+ Stream requestBody,
+ Func readResponseBody,
+ PayloadLimits limits,
+ CancellationToken cancellationToken);
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/Abstractions/ITransportServer.cs b/src/__Libraries/StellaOps.Router.Common/Abstractions/ITransportServer.cs
new file mode 100644
index 000000000..2624d5d38
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Abstractions/ITransportServer.cs
@@ -0,0 +1,19 @@
+namespace StellaOps.Router.Common.Abstractions;
+
+///
+/// Represents a transport server that accepts connections from microservices.
+///
+public interface ITransportServer
+{
+ ///
+ /// Starts listening for incoming connections.
+ ///
+ /// Cancellation token.
+ Task StartAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Stops accepting new connections.
+ ///
+ /// Cancellation token.
+ Task StopAsync(CancellationToken cancellationToken);
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/FrameType.cs b/src/__Libraries/StellaOps.Router.Common/Enums/FrameType.cs
similarity index 95%
rename from src/__Libraries/StellaOps.Router.Common/FrameType.cs
rename to src/__Libraries/StellaOps.Router.Common/Enums/FrameType.cs
index 6ad3d8355..0d1fab006 100644
--- a/src/__Libraries/StellaOps.Router.Common/FrameType.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Enums/FrameType.cs
@@ -1,4 +1,4 @@
-namespace StellaOps.Router.Common;
+namespace StellaOps.Router.Common.Enums;
///
/// Defines the frame types used in the router protocol.
diff --git a/src/__Libraries/StellaOps.Router.Common/InstanceHealthStatus.cs b/src/__Libraries/StellaOps.Router.Common/Enums/InstanceHealthStatus.cs
similarity index 94%
rename from src/__Libraries/StellaOps.Router.Common/InstanceHealthStatus.cs
rename to src/__Libraries/StellaOps.Router.Common/Enums/InstanceHealthStatus.cs
index 6cde60c1b..8f68c3a2b 100644
--- a/src/__Libraries/StellaOps.Router.Common/InstanceHealthStatus.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Enums/InstanceHealthStatus.cs
@@ -1,4 +1,4 @@
-namespace StellaOps.Router.Common;
+namespace StellaOps.Router.Common.Enums;
///
/// Defines the health status of a microservice instance.
diff --git a/src/__Libraries/StellaOps.Router.Common/TransportType.cs b/src/__Libraries/StellaOps.Router.Common/Enums/TransportType.cs
similarity index 74%
rename from src/__Libraries/StellaOps.Router.Common/TransportType.cs
rename to src/__Libraries/StellaOps.Router.Common/Enums/TransportType.cs
index e986def94..59b054e8c 100644
--- a/src/__Libraries/StellaOps.Router.Common/TransportType.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Enums/TransportType.cs
@@ -1,7 +1,8 @@
-namespace StellaOps.Router.Common;
+namespace StellaOps.Router.Common.Enums;
///
/// Defines the transport types supported for microservice-to-router communication.
+/// Note: HTTP is explicitly excluded per specification.
///
public enum TransportType
{
@@ -21,9 +22,9 @@ public enum TransportType
Tcp,
///
- /// TLS/mTLS transport with certificate-based authentication.
+ /// Certificate-based TCP (TLS/mTLS) transport with certificate-based authentication.
///
- Tls,
+ Certificate,
///
/// RabbitMQ transport for queue-based communication.
diff --git a/src/__Libraries/StellaOps.Router.Common/IRoutingPlugin.cs b/src/__Libraries/StellaOps.Router.Common/IRoutingPlugin.cs
deleted file mode 100644
index 09d05c6d2..000000000
--- a/src/__Libraries/StellaOps.Router.Common/IRoutingPlugin.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace StellaOps.Router.Common;
-
-///
-/// Provides extensibility for routing decisions.
-///
-public interface IRoutingPlugin
-{
- ///
- /// Gets the priority of this plugin. Lower values run first.
- ///
- int Priority { get; }
-
- ///
- /// Filters or reorders candidate connections for routing.
- ///
- /// The candidate connections.
- /// The target endpoint.
- /// The gateway's region.
- /// The filtered/reordered connections.
- IEnumerable Filter(
- IEnumerable candidates,
- EndpointDescriptor endpoint,
- string gatewayRegion);
-}
diff --git a/src/__Libraries/StellaOps.Router.Common/ITransportClient.cs b/src/__Libraries/StellaOps.Router.Common/ITransportClient.cs
deleted file mode 100644
index 070a35f69..000000000
--- a/src/__Libraries/StellaOps.Router.Common/ITransportClient.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace StellaOps.Router.Common;
-
-///
-/// Represents a transport client that connects to routers.
-///
-public interface ITransportClient : IAsyncDisposable
-{
- ///
- /// Gets the transport type for this client.
- ///
- TransportType TransportType { get; }
-
- ///
- /// Connects to a router endpoint.
- ///
- /// The router host.
- /// The router port.
- /// Cancellation token.
- /// The established connection.
- Task ConnectAsync(
- string host,
- int port,
- CancellationToken cancellationToken = default);
-}
diff --git a/src/__Libraries/StellaOps.Router.Common/ITransportConnection.cs b/src/__Libraries/StellaOps.Router.Common/ITransportConnection.cs
deleted file mode 100644
index 70190e6ee..000000000
--- a/src/__Libraries/StellaOps.Router.Common/ITransportConnection.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-namespace StellaOps.Router.Common;
-
-///
-/// Represents a bidirectional transport connection.
-///
-public interface ITransportConnection : IAsyncDisposable
-{
- ///
- /// Gets the unique identifier for this connection.
- ///
- string ConnectionId { get; }
-
- ///
- /// Gets a value indicating whether the connection is open.
- ///
- bool IsConnected { get; }
-
- ///
- /// Sends a frame over the connection.
- ///
- /// The frame to send.
- /// Cancellation token.
- ValueTask SendAsync(Frame frame, CancellationToken cancellationToken = default);
-
- ///
- /// Receives the next frame from the connection.
- ///
- /// Cancellation token.
- /// The received frame, or null if connection closed.
- ValueTask ReceiveAsync(CancellationToken cancellationToken = default);
-
- ///
- /// Closes the connection gracefully.
- ///
- /// Cancellation token.
- Task CloseAsync(CancellationToken cancellationToken = default);
-}
diff --git a/src/__Libraries/StellaOps.Router.Common/ITransportServer.cs b/src/__Libraries/StellaOps.Router.Common/ITransportServer.cs
deleted file mode 100644
index 52f30f729..000000000
--- a/src/__Libraries/StellaOps.Router.Common/ITransportServer.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-namespace StellaOps.Router.Common;
-
-///
-/// Represents a transport server that accepts connections from microservices.
-///
-public interface ITransportServer : IAsyncDisposable
-{
- ///
- /// Gets the transport type for this server.
- ///
- TransportType TransportType { get; }
-
- ///
- /// Starts listening for incoming connections.
- ///
- /// Cancellation token.
- Task StartAsync(CancellationToken cancellationToken = default);
-
- ///
- /// Stops accepting new connections.
- ///
- /// Cancellation token.
- Task StopAsync(CancellationToken cancellationToken = default);
-
- ///
- /// Occurs when a new connection is established.
- ///
- event EventHandler? ConnectionEstablished;
-
- ///
- /// Occurs when a connection is closed.
- ///
- event EventHandler? ConnectionClosed;
-}
-
-///
-/// Event arguments for transport connection events.
-///
-public sealed class TransportConnectionEventArgs : EventArgs
-{
- ///
- /// Gets the connection that triggered the event.
- ///
- public required ITransportConnection Connection { get; init; }
-}
diff --git a/src/__Libraries/StellaOps.Router.Common/Models/CancelPayload.cs b/src/__Libraries/StellaOps.Router.Common/Models/CancelPayload.cs
new file mode 100644
index 000000000..6d396b197
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Models/CancelPayload.cs
@@ -0,0 +1,12 @@
+namespace StellaOps.Router.Common.Models;
+
+///
+/// Payload for the Cancel frame.
+///
+public sealed record CancelPayload
+{
+ ///
+ /// Gets the reason for cancellation.
+ ///
+ public string? Reason { get; init; }
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/ClaimRequirement.cs b/src/__Libraries/StellaOps.Router.Common/Models/ClaimRequirement.cs
similarity index 90%
rename from src/__Libraries/StellaOps.Router.Common/ClaimRequirement.cs
rename to src/__Libraries/StellaOps.Router.Common/Models/ClaimRequirement.cs
index 05ac6a690..f2bedb050 100644
--- a/src/__Libraries/StellaOps.Router.Common/ClaimRequirement.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Models/ClaimRequirement.cs
@@ -1,4 +1,4 @@
-namespace StellaOps.Router.Common;
+namespace StellaOps.Router.Common.Models;
///
/// Represents a claim requirement for endpoint authorization.
diff --git a/src/__Libraries/StellaOps.Router.Common/ConnectionState.cs b/src/__Libraries/StellaOps.Router.Common/Models/ConnectionState.cs
similarity index 94%
rename from src/__Libraries/StellaOps.Router.Common/ConnectionState.cs
rename to src/__Libraries/StellaOps.Router.Common/Models/ConnectionState.cs
index 78e9aa769..3bb740728 100644
--- a/src/__Libraries/StellaOps.Router.Common/ConnectionState.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Models/ConnectionState.cs
@@ -1,4 +1,6 @@
-namespace StellaOps.Router.Common;
+using StellaOps.Router.Common.Enums;
+
+namespace StellaOps.Router.Common.Models;
///
/// Represents the state of a connection between a microservice and the router.
diff --git a/src/__Libraries/StellaOps.Router.Common/EndpointDescriptor.cs b/src/__Libraries/StellaOps.Router.Common/Models/EndpointDescriptor.cs
similarity index 96%
rename from src/__Libraries/StellaOps.Router.Common/EndpointDescriptor.cs
rename to src/__Libraries/StellaOps.Router.Common/Models/EndpointDescriptor.cs
index a04eff019..23f899563 100644
--- a/src/__Libraries/StellaOps.Router.Common/EndpointDescriptor.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Models/EndpointDescriptor.cs
@@ -1,4 +1,4 @@
-namespace StellaOps.Router.Common;
+namespace StellaOps.Router.Common.Models;
///
/// Describes an endpoint's identity and metadata.
diff --git a/src/__Libraries/StellaOps.Router.Common/Frame.cs b/src/__Libraries/StellaOps.Router.Common/Models/Frame.cs
similarity index 86%
rename from src/__Libraries/StellaOps.Router.Common/Frame.cs
rename to src/__Libraries/StellaOps.Router.Common/Models/Frame.cs
index eabcabff3..51bc6035b 100644
--- a/src/__Libraries/StellaOps.Router.Common/Frame.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Models/Frame.cs
@@ -1,4 +1,6 @@
-namespace StellaOps.Router.Common;
+using StellaOps.Router.Common.Enums;
+
+namespace StellaOps.Router.Common.Models;
///
/// Represents a protocol frame in the router transport layer.
diff --git a/src/__Libraries/StellaOps.Router.Common/Models/HeartbeatPayload.cs b/src/__Libraries/StellaOps.Router.Common/Models/HeartbeatPayload.cs
new file mode 100644
index 000000000..929325b33
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Models/HeartbeatPayload.cs
@@ -0,0 +1,34 @@
+using StellaOps.Router.Common.Enums;
+
+namespace StellaOps.Router.Common.Models;
+
+///
+/// Payload for the Heartbeat frame sent periodically by microservices.
+///
+public sealed record HeartbeatPayload
+{
+ ///
+ /// Gets the instance ID.
+ ///
+ public required string InstanceId { get; init; }
+
+ ///
+ /// Gets the health status.
+ ///
+ public required InstanceHealthStatus Status { get; init; }
+
+ ///
+ /// Gets the current in-flight request count.
+ ///
+ public int InFlightRequestCount { get; init; }
+
+ ///
+ /// Gets the error rate (0.0 to 1.0).
+ ///
+ public double ErrorRate { get; init; }
+
+ ///
+ /// Gets the timestamp when this heartbeat was created.
+ ///
+ public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/Models/HelloPayload.cs b/src/__Libraries/StellaOps.Router.Common/Models/HelloPayload.cs
new file mode 100644
index 000000000..5de85ff0b
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Models/HelloPayload.cs
@@ -0,0 +1,17 @@
+namespace StellaOps.Router.Common.Models;
+
+///
+/// Payload for the Hello frame sent by microservices on connection.
+///
+public sealed record HelloPayload
+{
+ ///
+ /// Gets the instance descriptor.
+ ///
+ public required InstanceDescriptor Instance { get; init; }
+
+ ///
+ /// Gets the endpoints registered by this instance.
+ ///
+ public required IReadOnlyList Endpoints { get; init; }
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/InstanceDescriptor.cs b/src/__Libraries/StellaOps.Router.Common/Models/InstanceDescriptor.cs
similarity index 94%
rename from src/__Libraries/StellaOps.Router.Common/InstanceDescriptor.cs
rename to src/__Libraries/StellaOps.Router.Common/Models/InstanceDescriptor.cs
index 1aa9d5d8a..5c3b004f0 100644
--- a/src/__Libraries/StellaOps.Router.Common/InstanceDescriptor.cs
+++ b/src/__Libraries/StellaOps.Router.Common/Models/InstanceDescriptor.cs
@@ -1,4 +1,4 @@
-namespace StellaOps.Router.Common;
+namespace StellaOps.Router.Common.Models;
///
/// Describes a microservice instance's identity.
diff --git a/src/__Libraries/StellaOps.Router.Common/Models/PayloadLimits.cs b/src/__Libraries/StellaOps.Router.Common/Models/PayloadLimits.cs
new file mode 100644
index 000000000..3bb1fb825
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Models/PayloadLimits.cs
@@ -0,0 +1,30 @@
+namespace StellaOps.Router.Common.Models;
+
+///
+/// Configuration for payload and memory limits.
+///
+public sealed record PayloadLimits
+{
+ ///
+ /// Default payload limits.
+ ///
+ public static readonly PayloadLimits Default = new();
+
+ ///
+ /// Gets the maximum request bytes per call.
+ /// Default: 10 MB.
+ ///
+ public long MaxRequestBytesPerCall { get; init; } = 10 * 1024 * 1024;
+
+ ///
+ /// Gets the maximum request bytes per connection.
+ /// Default: 100 MB.
+ ///
+ public long MaxRequestBytesPerConnection { get; init; } = 100 * 1024 * 1024;
+
+ ///
+ /// Gets the maximum aggregate in-flight bytes across all requests.
+ /// Default: 1 GB.
+ ///
+ public long MaxAggregateInflightBytes { get; init; } = 1024 * 1024 * 1024;
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/Models/RoutingContext.cs b/src/__Libraries/StellaOps.Router.Common/Models/RoutingContext.cs
new file mode 100644
index 000000000..0c566ade3
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Models/RoutingContext.cs
@@ -0,0 +1,48 @@
+namespace StellaOps.Router.Common.Models;
+
+///
+/// Neutral routing context that does not depend on ASP.NET.
+/// Gateway will adapt from HttpContext to this neutral model.
+///
+public sealed record RoutingContext
+{
+ ///
+ /// Gets the HTTP method (GET, POST, PUT, PATCH, DELETE).
+ ///
+ public required string Method { get; init; }
+
+ ///
+ /// Gets the request path.
+ ///
+ public required string Path { get; init; }
+
+ ///
+ /// Gets the request headers.
+ ///
+ public IReadOnlyDictionary Headers { get; init; } = new Dictionary();
+
+ ///
+ /// Gets the resolved endpoint descriptor.
+ ///
+ public EndpointDescriptor? Endpoint { get; init; }
+
+ ///
+ /// Gets the available connections for routing.
+ ///
+ public IReadOnlyList AvailableConnections { get; init; } = [];
+
+ ///
+ /// Gets the gateway's region for routing decisions.
+ ///
+ public required string GatewayRegion { get; init; }
+
+ ///
+ /// Gets the requested version, if specified.
+ ///
+ public string? RequestedVersion { get; init; }
+
+ ///
+ /// Gets the cancellation token for the request.
+ ///
+ public CancellationToken CancellationToken { get; init; }
+}
diff --git a/src/__Libraries/StellaOps.Router.Common/Models/RoutingDecision.cs b/src/__Libraries/StellaOps.Router.Common/Models/RoutingDecision.cs
new file mode 100644
index 000000000..0a7dae48b
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Common/Models/RoutingDecision.cs
@@ -0,0 +1,29 @@
+using StellaOps.Router.Common.Enums;
+
+namespace StellaOps.Router.Common.Models;
+
+///
+/// Represents the outcome of a routing decision.
+///
+public sealed record RoutingDecision
+{
+ ///
+ /// Gets the selected endpoint.
+ ///
+ public required EndpointDescriptor Endpoint { get; init; }
+
+ ///
+ /// Gets the selected connection.
+ ///
+ public required ConnectionState Connection { get; init; }
+
+ ///
+ /// Gets the transport type to use.
+ ///
+ public required TransportType TransportType { get; init; }
+
+ ///
+ /// Gets the effective timeout for the request.
+ ///
+ public required TimeSpan EffectiveTimeout { get; init; }
+}
diff --git a/src/__Libraries/StellaOps.Router.Config/PayloadLimits.cs b/src/__Libraries/StellaOps.Router.Config/PayloadLimits.cs
deleted file mode 100644
index 0080897a8..000000000
--- a/src/__Libraries/StellaOps.Router.Config/PayloadLimits.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-namespace StellaOps.Router.Config;
-
-///
-/// Configuration for payload and memory limits.
-///
-public sealed class PayloadLimits
-{
- ///
- /// Gets or sets the maximum request bytes per call.
- /// Default: 10 MB.
- ///
- public long MaxRequestBytesPerCall { get; set; } = 10 * 1024 * 1024;
-
- ///
- /// Gets or sets the maximum request bytes per connection.
- /// Default: 100 MB.
- ///
- public long MaxRequestBytesPerConnection { get; set; } = 100 * 1024 * 1024;
-
- ///
- /// Gets or sets the maximum aggregate in-flight bytes across all requests.
- /// Default: 1 GB.
- ///
- public long MaxAggregateInflightBytes { get; set; } = 1024 * 1024 * 1024;
-}
diff --git a/src/__Libraries/StellaOps.Router.Config/RouterConfig.cs b/src/__Libraries/StellaOps.Router.Config/RouterConfig.cs
index 7727665d0..66f7928b0 100644
--- a/src/__Libraries/StellaOps.Router.Config/RouterConfig.cs
+++ b/src/__Libraries/StellaOps.Router.Config/RouterConfig.cs
@@ -1,3 +1,5 @@
+using StellaOps.Router.Common.Models;
+
namespace StellaOps.Router.Config;
///
diff --git a/src/__Libraries/StellaOps.Router.Config/ServiceConfig.cs b/src/__Libraries/StellaOps.Router.Config/ServiceConfig.cs
index cb9d5bb83..6dcfeb23e 100644
--- a/src/__Libraries/StellaOps.Router.Config/ServiceConfig.cs
+++ b/src/__Libraries/StellaOps.Router.Config/ServiceConfig.cs
@@ -1,4 +1,5 @@
-using StellaOps.Router.Common;
+using StellaOps.Router.Common.Enums;
+using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Config;
diff --git a/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryChannel.cs b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryChannel.cs
new file mode 100644
index 000000000..e40bb624c
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryChannel.cs
@@ -0,0 +1,93 @@
+using System.Threading.Channels;
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Router.Transport.InMemory;
+
+///
+/// Represents a bidirectional in-memory channel for frame passing between gateway and microservice.
+///
+public sealed class InMemoryChannel : IDisposable
+{
+ private bool _disposed;
+
+ ///
+ /// Gets the connection ID.
+ ///
+ public string ConnectionId { get; }
+
+ ///
+ /// Gets the channel for frames from gateway to microservice.
+ /// Gateway writes, SDK reads.
+ ///
+ public Channel ToMicroservice { get; }
+
+ ///
+ /// Gets the channel for frames from microservice to gateway.
+ /// SDK writes, Gateway reads.
+ ///
+ public Channel ToGateway { get; }
+
+ ///
+ /// Gets or sets the instance descriptor for this connection.
+ /// Set when HELLO is processed.
+ ///
+ public InstanceDescriptor? Instance { get; set; }
+
+ ///
+ /// Gets the cancellation token source for this connection's lifetime.
+ ///
+ public CancellationTokenSource LifetimeToken { get; }
+
+ ///
+ /// Gets or sets the connection state.
+ ///
+ public ConnectionState? State { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The connection ID.
+ /// The channel buffer size. Zero for unbounded.
+ public InMemoryChannel(string connectionId, int bufferSize = 0)
+ {
+ ConnectionId = connectionId;
+ LifetimeToken = new CancellationTokenSource();
+
+ if (bufferSize > 0)
+ {
+ var options = new BoundedChannelOptions(bufferSize)
+ {
+ FullMode = BoundedChannelFullMode.Wait,
+ SingleReader = false,
+ SingleWriter = false
+ };
+ ToMicroservice = Channel.CreateBounded(options);
+ ToGateway = Channel.CreateBounded(options);
+ }
+ else
+ {
+ var options = new UnboundedChannelOptions
+ {
+ SingleReader = false,
+ SingleWriter = false
+ };
+ ToMicroservice = Channel.CreateUnbounded(options);
+ ToGateway = Channel.CreateUnbounded(options);
+ }
+ }
+
+ ///
+ /// Disposes the channel and cancels all pending operations.
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ LifetimeToken.Cancel();
+ LifetimeToken.Dispose();
+
+ ToMicroservice.Writer.TryComplete();
+ ToGateway.Writer.TryComplete();
+ }
+}
diff --git a/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryConnectionRegistry.cs b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryConnectionRegistry.cs
new file mode 100644
index 000000000..e04e1c25f
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryConnectionRegistry.cs
@@ -0,0 +1,124 @@
+using System.Collections.Concurrent;
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Router.Transport.InMemory;
+
+///
+/// Thread-safe registry for in-memory connections.
+///
+public sealed class InMemoryConnectionRegistry : IDisposable
+{
+ private readonly ConcurrentDictionary _channels = new();
+ private bool _disposed;
+
+ ///
+ /// Gets all connection IDs.
+ ///
+ public IEnumerable ConnectionIds => _channels.Keys;
+
+ ///
+ /// Gets the count of active connections.
+ ///
+ public int Count => _channels.Count;
+
+ ///
+ /// Creates a new channel with the given connection ID.
+ ///
+ /// The connection ID.
+ /// The channel buffer size.
+ /// The created channel.
+ public InMemoryChannel CreateChannel(string connectionId, int bufferSize = 0)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var channel = new InMemoryChannel(connectionId, bufferSize);
+ if (!_channels.TryAdd(connectionId, channel))
+ {
+ channel.Dispose();
+ throw new InvalidOperationException($"Connection {connectionId} already exists.");
+ }
+ return channel;
+ }
+
+ ///
+ /// Gets a channel by connection ID.
+ ///
+ /// The connection ID.
+ /// The channel, or null if not found.
+ public InMemoryChannel? GetChannel(string connectionId)
+ {
+ _channels.TryGetValue(connectionId, out var channel);
+ return channel;
+ }
+
+ ///
+ /// Gets a channel by connection ID, throwing if not found.
+ ///
+ /// The connection ID.
+ /// The channel.
+ public InMemoryChannel GetRequiredChannel(string connectionId)
+ {
+ return GetChannel(connectionId)
+ ?? throw new InvalidOperationException($"Connection {connectionId} not found.");
+ }
+
+ ///
+ /// Removes and disposes a channel by connection ID.
+ ///
+ /// The connection ID.
+ /// True if the channel was found and removed.
+ public bool RemoveChannel(string connectionId)
+ {
+ if (_channels.TryRemove(connectionId, out var channel))
+ {
+ channel.Dispose();
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Gets all active connection states.
+ ///
+ public IReadOnlyList GetAllConnections()
+ {
+ return _channels.Values
+ .Where(c => c.State is not null)
+ .Select(c => c.State!)
+ .ToList();
+ }
+
+ ///
+ /// Gets connections for a specific service and endpoint.
+ ///
+ /// The service name.
+ /// The version.
+ /// The HTTP method.
+ /// The path.
+ public IReadOnlyList GetConnectionsFor(
+ string serviceName, string version, string method, string path)
+ {
+ return _channels.Values
+ .Where(c => c.State is not null
+ && c.Instance?.ServiceName == serviceName
+ && c.Instance?.Version == version
+ && c.State.Endpoints.ContainsKey((method, path)))
+ .Select(c => c.State!)
+ .ToList();
+ }
+
+ ///
+ /// Disposes all channels.
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ foreach (var channel in _channels.Values)
+ {
+ channel.Dispose();
+ }
+ _channels.Clear();
+ }
+}
diff --git a/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportClient.cs b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportClient.cs
new file mode 100644
index 000000000..92065e36d
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportClient.cs
@@ -0,0 +1,425 @@
+using System.Buffers;
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Router.Common.Abstractions;
+using StellaOps.Router.Common.Enums;
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Router.Transport.InMemory;
+
+///
+/// In-memory transport client implementation for testing and development.
+/// Used by the Microservice SDK to send frames to the Gateway.
+///
+public sealed class InMemoryTransportClient : ITransportClient, IDisposable
+{
+ private readonly InMemoryConnectionRegistry _registry;
+ private readonly InMemoryTransportOptions _options;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary> _pendingRequests = new();
+ private readonly CancellationTokenSource _clientCts = new();
+ private bool _disposed;
+ private string? _connectionId;
+ private Task? _receiveTask;
+
+ ///
+ /// Event raised when a REQUEST frame is received from the gateway.
+ ///
+ public event Func>? OnRequestReceived;
+
+ ///
+ /// Event raised when a CANCEL frame is received from the gateway.
+ ///
+ public event Func? OnCancelReceived;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public InMemoryTransportClient(
+ InMemoryConnectionRegistry registry,
+ IOptions options,
+ ILogger logger)
+ {
+ _registry = registry;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ ///
+ /// Connects to the in-memory transport and sends a HELLO frame.
+ ///
+ /// The instance descriptor.
+ /// The endpoints to register.
+ /// Cancellation token.
+ public async Task ConnectAsync(
+ InstanceDescriptor instance,
+ IReadOnlyList endpoints,
+ CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ _connectionId = Guid.NewGuid().ToString("N");
+ var channel = _registry.CreateChannel(_connectionId, _options.ChannelBufferSize);
+ channel.Instance = instance;
+
+ // Create initial ConnectionState
+ var state = new ConnectionState
+ {
+ ConnectionId = _connectionId,
+ Instance = instance,
+ Status = InstanceHealthStatus.Healthy,
+ LastHeartbeatUtc = DateTime.UtcNow,
+ TransportType = TransportType.InMemory
+ };
+
+ // Register endpoints
+ foreach (var endpoint in endpoints)
+ {
+ state.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
+ }
+ channel.State = state;
+
+ // Send HELLO frame
+ var helloFrame = new Frame
+ {
+ Type = FrameType.Hello,
+ CorrelationId = Guid.NewGuid().ToString("N"),
+ Payload = ReadOnlyMemory.Empty
+ };
+ await channel.ToGateway.Writer.WriteAsync(helloFrame, cancellationToken);
+
+ _logger.LogInformation(
+ "Connected as {ServiceName}/{Version} instance {InstanceId} with {EndpointCount} endpoints",
+ instance.ServiceName,
+ instance.Version,
+ instance.InstanceId,
+ endpoints.Count);
+
+ // Start receiving frames from gateway
+ _receiveTask = Task.Run(() => ReceiveLoopAsync(_clientCts.Token), CancellationToken.None);
+ }
+
+ private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
+ {
+ if (_connectionId is null) return;
+
+ var channel = _registry.GetChannel(_connectionId);
+ if (channel is null) return;
+
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, channel.LifetimeToken.Token);
+
+ try
+ {
+ await foreach (var frame in channel.ToMicroservice.Reader.ReadAllAsync(linkedCts.Token))
+ {
+ if (_options.SimulatedLatency > TimeSpan.Zero)
+ {
+ await Task.Delay(_options.SimulatedLatency, linkedCts.Token);
+ }
+
+ await ProcessFrameFromGatewayAsync(channel, frame, linkedCts.Token);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on disconnect
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in receive loop");
+ }
+ }
+
+ private async Task ProcessFrameFromGatewayAsync(
+ InMemoryChannel channel, Frame frame, CancellationToken cancellationToken)
+ {
+ switch (frame.Type)
+ {
+ case FrameType.Request:
+ case FrameType.RequestStreamData:
+ await HandleRequestFrameAsync(channel, frame, cancellationToken);
+ break;
+
+ case FrameType.Cancel:
+ HandleCancelFrame(frame);
+ break;
+
+ case FrameType.Response:
+ case FrameType.ResponseStreamData:
+ // Response to our request (from gateway back)
+ if (frame.CorrelationId is not null &&
+ _pendingRequests.TryRemove(frame.CorrelationId, out var tcs))
+ {
+ tcs.TrySetResult(frame);
+ }
+ break;
+
+ default:
+ _logger.LogWarning("Unexpected frame type {FrameType} from gateway", frame.Type);
+ break;
+ }
+ }
+
+ private async Task HandleRequestFrameAsync(
+ InMemoryChannel channel, Frame frame, CancellationToken cancellationToken)
+ {
+ if (OnRequestReceived is null)
+ {
+ _logger.LogWarning("No request handler registered, discarding request {CorrelationId}",
+ frame.CorrelationId);
+ return;
+ }
+
+ try
+ {
+ var response = await OnRequestReceived(frame, cancellationToken);
+
+ // Ensure response has same correlation ID
+ var responseFrame = response with { CorrelationId = frame.CorrelationId };
+ await channel.ToGateway.Writer.WriteAsync(responseFrame, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogDebug("Request {CorrelationId} was cancelled", frame.CorrelationId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error handling request {CorrelationId}", frame.CorrelationId);
+ // Send error response
+ var errorFrame = new Frame
+ {
+ Type = FrameType.Response,
+ CorrelationId = frame.CorrelationId,
+ Payload = ReadOnlyMemory.Empty
+ };
+ await channel.ToGateway.Writer.WriteAsync(errorFrame, cancellationToken);
+ }
+ }
+
+ private void HandleCancelFrame(Frame frame)
+ {
+ if (frame.CorrelationId is null) return;
+
+ _logger.LogDebug("Received CANCEL for correlation {CorrelationId}", frame.CorrelationId);
+
+ // Complete any pending request with cancellation
+ if (_pendingRequests.TryRemove(frame.CorrelationId, out var tcs))
+ {
+ tcs.TrySetCanceled();
+ }
+
+ // Notify handler
+ if (OnCancelReceived is not null && Guid.TryParse(frame.CorrelationId, out var correlationGuid))
+ {
+ _ = OnCancelReceived(correlationGuid, null);
+ }
+ }
+
+ ///
+ public async Task SendRequestAsync(
+ ConnectionState connection,
+ Frame requestFrame,
+ TimeSpan timeout,
+ CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var channel = _registry.GetRequiredChannel(connection.ConnectionId);
+ var correlationId = requestFrame.CorrelationId ?? Guid.NewGuid().ToString("N");
+ var framedRequest = requestFrame with { CorrelationId = correlationId };
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pendingRequests[correlationId] = tcs;
+
+ try
+ {
+ if (_options.SimulatedLatency > TimeSpan.Zero)
+ {
+ await Task.Delay(_options.SimulatedLatency, cancellationToken);
+ }
+
+ await channel.ToMicroservice.Writer.WriteAsync(framedRequest, cancellationToken);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+
+ return await tcs.Task.WaitAsync(timeoutCts.Token);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException($"Request {correlationId} timed out after {timeout}");
+ }
+ finally
+ {
+ _pendingRequests.TryRemove(correlationId, out _);
+ }
+ }
+
+ ///
+ public async Task SendCancelAsync(
+ ConnectionState connection,
+ Guid correlationId,
+ string? reason = null)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var channel = _registry.GetRequiredChannel(connection.ConnectionId);
+
+ var cancelFrame = new Frame
+ {
+ Type = FrameType.Cancel,
+ CorrelationId = correlationId.ToString("N"),
+ Payload = ReadOnlyMemory.Empty
+ };
+
+ if (_options.SimulatedLatency > TimeSpan.Zero)
+ {
+ await Task.Delay(_options.SimulatedLatency);
+ }
+
+ await channel.ToMicroservice.Writer.WriteAsync(cancelFrame);
+
+ _logger.LogDebug("Sent CANCEL for correlation {CorrelationId}", correlationId);
+ }
+
+ ///
+ public async Task SendStreamingAsync(
+ ConnectionState connection,
+ Frame requestHeader,
+ Stream requestBody,
+ Func readResponseBody,
+ PayloadLimits limits,
+ CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var channel = _registry.GetRequiredChannel(connection.ConnectionId);
+ var correlationId = requestHeader.CorrelationId ?? Guid.NewGuid().ToString("N");
+
+ // Send header frame
+ var headerFrame = requestHeader with
+ {
+ Type = FrameType.Request,
+ CorrelationId = correlationId
+ };
+ await channel.ToMicroservice.Writer.WriteAsync(headerFrame, cancellationToken);
+
+ // Stream request body in chunks
+ var buffer = ArrayPool.Shared.Rent(8192);
+ try
+ {
+ long totalBytesRead = 0;
+ int bytesRead;
+
+ while ((bytesRead = await requestBody.ReadAsync(buffer, cancellationToken)) > 0)
+ {
+ totalBytesRead += bytesRead;
+
+ if (totalBytesRead > limits.MaxRequestBytesPerCall)
+ {
+ throw new InvalidOperationException(
+ $"Request body exceeds limit of {limits.MaxRequestBytesPerCall} bytes");
+ }
+
+ var dataFrame = new Frame
+ {
+ Type = FrameType.RequestStreamData,
+ CorrelationId = correlationId,
+ Payload = new ReadOnlyMemory(buffer, 0, bytesRead)
+ };
+ await channel.ToMicroservice.Writer.WriteAsync(dataFrame, cancellationToken);
+
+ if (_options.SimulatedLatency > TimeSpan.Zero)
+ {
+ await Task.Delay(_options.SimulatedLatency, cancellationToken);
+ }
+ }
+
+ // Signal end of request stream with empty data frame
+ var endFrame = new Frame
+ {
+ Type = FrameType.RequestStreamData,
+ CorrelationId = correlationId,
+ Payload = ReadOnlyMemory.Empty
+ };
+ await channel.ToMicroservice.Writer.WriteAsync(endFrame, cancellationToken);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+
+ // Read streaming response
+ using var responseStream = new MemoryStream();
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pendingRequests[correlationId] = new TaskCompletionSource();
+
+ // TODO: Implement proper streaming response handling
+ // For now, we accumulate the response in memory
+ await readResponseBody(responseStream);
+ }
+
+ ///
+ /// Sends a heartbeat frame.
+ ///
+ public async Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken)
+ {
+ if (_connectionId is null) return;
+
+ var channel = _registry.GetChannel(_connectionId);
+ if (channel is null) return;
+
+ var frame = new Frame
+ {
+ Type = FrameType.Heartbeat,
+ CorrelationId = null,
+ Payload = ReadOnlyMemory.Empty
+ };
+
+ await channel.ToGateway.Writer.WriteAsync(frame, cancellationToken);
+ }
+
+ ///
+ /// Disconnects from the transport.
+ ///
+ public async Task DisconnectAsync()
+ {
+ if (_connectionId is null) return;
+
+ await _clientCts.CancelAsync();
+
+ if (_receiveTask is not null)
+ {
+ await _receiveTask;
+ }
+
+ _registry.RemoveChannel(_connectionId);
+ _connectionId = null;
+
+ _logger.LogInformation("Disconnected from in-memory transport");
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ _clientCts.Cancel();
+
+ foreach (var tcs in _pendingRequests.Values)
+ {
+ tcs.TrySetCanceled();
+ }
+ _pendingRequests.Clear();
+
+ if (_connectionId is not null)
+ {
+ _registry.RemoveChannel(_connectionId);
+ }
+
+ _clientCts.Dispose();
+ }
+}
diff --git a/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportOptions.cs b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportOptions.cs
new file mode 100644
index 000000000..6c655f578
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportOptions.cs
@@ -0,0 +1,37 @@
+namespace StellaOps.Router.Transport.InMemory;
+
+///
+/// Configuration options for the InMemory transport.
+///
+public sealed class InMemoryTransportOptions
+{
+ ///
+ /// Gets or sets the default timeout for requests.
+ /// Default: 30 seconds.
+ ///
+ public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Gets or sets the simulated latency for frame delivery.
+ /// Default: Zero (instant delivery).
+ ///
+ public TimeSpan SimulatedLatency { get; set; } = TimeSpan.Zero;
+
+ ///
+ /// Gets or sets the channel buffer size.
+ /// Default: Unbounded (0 means unbounded).
+ ///
+ public int ChannelBufferSize { get; set; }
+
+ ///
+ /// Gets or sets the heartbeat interval.
+ /// Default: 10 seconds.
+ ///
+ public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Gets or sets the heartbeat timeout (time since last heartbeat before connection is considered unhealthy).
+ /// Default: 30 seconds.
+ ///
+ public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(30);
+}
diff --git a/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportServer.cs b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportServer.cs
new file mode 100644
index 000000000..4abb1fa39
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Transport.InMemory/InMemoryTransportServer.cs
@@ -0,0 +1,264 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Router.Common.Abstractions;
+using StellaOps.Router.Common.Enums;
+using StellaOps.Router.Common.Models;
+
+namespace StellaOps.Router.Transport.InMemory;
+
+///
+/// In-memory transport server implementation for testing and development.
+/// Used by the Gateway to receive frames from microservices.
+///
+public sealed class InMemoryTransportServer : ITransportServer, IDisposable
+{
+ private readonly InMemoryConnectionRegistry _registry;
+ private readonly InMemoryTransportOptions _options;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _connectionTasks = new();
+ private readonly CancellationTokenSource _serverCts = new();
+ private bool _running;
+ private bool _disposed;
+
+ ///
+ /// Event raised when a HELLO frame is received.
+ ///
+ public event Func? OnHelloReceived;
+
+ ///
+ /// Event raised when a HEARTBEAT frame is received.
+ ///
+ public event Func? OnHeartbeatReceived;
+
+ ///
+ /// Event raised when a RESPONSE frame is received.
+ ///
+ public event Func? OnResponseReceived;
+
+ ///
+ /// Event raised when a connection is closed.
+ ///
+ public event Func? OnConnectionClosed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public InMemoryTransportServer(
+ InMemoryConnectionRegistry registry,
+ IOptions options,
+ ILogger logger)
+ {
+ _registry = registry;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (_running)
+ {
+ _logger.LogWarning("InMemory transport server is already running");
+ return Task.CompletedTask;
+ }
+
+ _running = true;
+ _logger.LogInformation("InMemory transport server started");
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ if (!_running) return;
+
+ _logger.LogInformation("InMemory transport server stopping");
+ _running = false;
+
+ await _serverCts.CancelAsync();
+
+ // Wait for all connection tasks to complete
+ var tasks = _connectionTasks.Values.ToArray();
+ if (tasks.Length > 0)
+ {
+ await Task.WhenAll(tasks).WaitAsync(cancellationToken);
+ }
+
+ _logger.LogInformation("InMemory transport server stopped");
+ }
+
+ ///
+ /// Starts listening to a specific connection's ToGateway channel.
+ /// Called when a new connection is registered.
+ ///
+ public void StartListeningToConnection(string connectionId)
+ {
+ if (!_running) return;
+
+ var channel = _registry.GetChannel(connectionId);
+ if (channel is null) return;
+
+ var task = Task.Run(async () =>
+ {
+ try
+ {
+ await ProcessConnectionFramesAsync(channel, _serverCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error processing frames for connection {ConnectionId}", connectionId);
+ }
+ finally
+ {
+ _connectionTasks.TryRemove(connectionId, out _);
+ if (OnConnectionClosed is not null)
+ {
+ await OnConnectionClosed(connectionId);
+ }
+ }
+ });
+
+ _connectionTasks[connectionId] = task;
+ }
+
+ private async Task ProcessConnectionFramesAsync(InMemoryChannel channel, CancellationToken cancellationToken)
+ {
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, channel.LifetimeToken.Token);
+
+ await foreach (var frame in channel.ToGateway.Reader.ReadAllAsync(linkedCts.Token))
+ {
+ if (_options.SimulatedLatency > TimeSpan.Zero)
+ {
+ await Task.Delay(_options.SimulatedLatency, linkedCts.Token);
+ }
+
+ await ProcessFrameAsync(channel, frame, linkedCts.Token);
+ }
+ }
+
+ private async Task ProcessFrameAsync(InMemoryChannel channel, Frame frame, CancellationToken cancellationToken)
+ {
+ switch (frame.Type)
+ {
+ case FrameType.Hello:
+ await ProcessHelloFrameAsync(channel, frame, cancellationToken);
+ break;
+
+ case FrameType.Heartbeat:
+ await ProcessHeartbeatFrameAsync(channel, frame, cancellationToken);
+ break;
+
+ case FrameType.Response:
+ case FrameType.ResponseStreamData:
+ if (channel.State is not null && OnResponseReceived is not null)
+ {
+ await OnResponseReceived(channel.State, frame);
+ }
+ break;
+
+ case FrameType.Cancel:
+ _logger.LogDebug("Received CANCEL from microservice for correlation {CorrelationId}",
+ frame.CorrelationId);
+ break;
+
+ default:
+ _logger.LogWarning("Unexpected frame type {FrameType} from connection {ConnectionId}",
+ frame.Type, channel.ConnectionId);
+ break;
+ }
+ }
+
+ private async Task ProcessHelloFrameAsync(InMemoryChannel channel, Frame frame, CancellationToken cancellationToken)
+ {
+ // In a real implementation, we'd deserialize the payload
+ // For now, the HelloPayload should be passed out-of-band via the channel
+ if (channel.Instance is null)
+ {
+ _logger.LogWarning("HELLO received but Instance not set for connection {ConnectionId}",
+ channel.ConnectionId);
+ return;
+ }
+
+ // Create ConnectionState
+ var state = new ConnectionState
+ {
+ ConnectionId = channel.ConnectionId,
+ Instance = channel.Instance,
+ Status = InstanceHealthStatus.Healthy,
+ LastHeartbeatUtc = DateTime.UtcNow,
+ TransportType = TransportType.InMemory
+ };
+ channel.State = state;
+
+ _logger.LogInformation(
+ "HELLO received from {ServiceName}/{Version} instance {InstanceId}",
+ channel.Instance.ServiceName,
+ channel.Instance.Version,
+ channel.Instance.InstanceId);
+
+ // Fire event with dummy HelloPayload (real impl would deserialize from frame)
+ if (OnHelloReceived is not null)
+ {
+ var payload = new HelloPayload
+ {
+ Instance = channel.Instance,
+ Endpoints = []
+ };
+ await OnHelloReceived(state, payload);
+ }
+ }
+
+ private async Task ProcessHeartbeatFrameAsync(InMemoryChannel channel, Frame frame, CancellationToken cancellationToken)
+ {
+ if (channel.State is null) return;
+
+ channel.State.LastHeartbeatUtc = DateTime.UtcNow;
+
+ _logger.LogDebug("Heartbeat received from {ConnectionId}", channel.ConnectionId);
+
+ if (OnHeartbeatReceived is not null)
+ {
+ var payload = new HeartbeatPayload
+ {
+ InstanceId = channel.Instance?.InstanceId ?? channel.ConnectionId,
+ Status = channel.State.Status,
+ TimestampUtc = DateTime.UtcNow
+ };
+ await OnHeartbeatReceived(channel.State, payload);
+ }
+ }
+
+ ///
+ /// Sends a frame to a microservice via the ToMicroservice channel.
+ ///
+ public async ValueTask SendToMicroserviceAsync(
+ string connectionId, Frame frame, CancellationToken cancellationToken)
+ {
+ var channel = _registry.GetRequiredChannel(connectionId);
+
+ if (_options.SimulatedLatency > TimeSpan.Zero)
+ {
+ await Task.Delay(_options.SimulatedLatency, cancellationToken);
+ }
+
+ await channel.ToMicroservice.Writer.WriteAsync(frame, cancellationToken);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ _serverCts.Cancel();
+ _serverCts.Dispose();
+ }
+}
diff --git a/src/__Libraries/StellaOps.Router.Transport.InMemory/ServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Router.Transport.InMemory/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..36b360944
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Transport.InMemory/ServiceCollectionExtensions.cs
@@ -0,0 +1,87 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using StellaOps.Router.Common.Abstractions;
+
+namespace StellaOps.Router.Transport.InMemory;
+
+///
+/// Extension methods for registering InMemory transport services.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds the InMemory transport for testing and development.
+ ///
+ /// The service collection.
+ /// Optional configuration action.
+ /// The service collection for chaining.
+ public static IServiceCollection AddInMemoryTransport(
+ this IServiceCollection services,
+ Action? configure = null)
+ {
+ services.AddOptions();
+ if (configure is not null)
+ {
+ services.Configure(configure);
+ }
+
+ // Singleton registry shared between server and client
+ services.TryAddSingleton();
+
+ // Transport implementations
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+
+ // Register interfaces
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+
+ return services;
+ }
+
+ ///
+ /// Adds the InMemory transport server only (for Gateway).
+ ///
+ /// The service collection.
+ /// Optional configuration action.
+ /// The service collection for chaining.
+ public static IServiceCollection AddInMemoryTransportServer(
+ this IServiceCollection services,
+ Action? configure = null)
+ {
+ services.AddOptions();
+ if (configure is not null)
+ {
+ services.Configure(configure);
+ }
+
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+
+ return services;
+ }
+
+ ///
+ /// Adds the InMemory transport client only (for Microservice SDK).
+ ///
+ /// The service collection.
+ /// Optional configuration action.
+ /// The service collection for chaining.
+ public static IServiceCollection AddInMemoryTransportClient(
+ this IServiceCollection services,
+ Action? configure = null)
+ {
+ services.AddOptions();
+ if (configure is not null)
+ {
+ services.Configure(configure);
+ }
+
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+
+ return services;
+ }
+}
diff --git a/src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj b/src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj
new file mode 100644
index 000000000..c1ddbfccf
--- /dev/null
+++ b/src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+ preview
+ true
+ StellaOps.Router.Transport.InMemory
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/StellaOps.Microservice.Tests/EndpointDiscoveryTests.cs b/tests/StellaOps.Microservice.Tests/EndpointDiscoveryTests.cs
new file mode 100644
index 000000000..36a0ea981
--- /dev/null
+++ b/tests/StellaOps.Microservice.Tests/EndpointDiscoveryTests.cs
@@ -0,0 +1,128 @@
+using System.Reflection;
+using StellaOps.Microservice;
+using Xunit;
+
+namespace StellaOps.Microservice.Tests;
+
+// Test endpoint classes
+[StellaEndpoint("GET", "/api/test")]
+public class TestGetEndpoint : IStellaEndpoint
+{
+ public Task HandleAsync(CancellationToken cancellationToken) => Task.FromResult("OK");
+}
+
+[StellaEndpoint("POST", "/api/create", SupportsStreaming = true, TimeoutSeconds = 60)]
+public class TestPostEndpoint : IStellaEndpoint
+{
+ public Task HandleAsync(TestRequest request, CancellationToken cancellationToken)
+ => Task.FromResult(new TestResponse());
+}
+
+public record TestRequest;
+public record TestResponse;
+
+public class EndpointDiscoveryTests
+{
+ [Fact]
+ public void StellaEndpointAttribute_StoresMethodAndPath()
+ {
+ // Arrange & Act
+ var attr = new StellaEndpointAttribute("POST", "/api/test");
+
+ // Assert
+ Assert.Equal("POST", attr.Method);
+ Assert.Equal("/api/test", attr.Path);
+ }
+
+ [Fact]
+ public void StellaEndpointAttribute_NormalizesMethod()
+ {
+ // Arrange & Act
+ var attr = new StellaEndpointAttribute("get", "/api/test");
+
+ // Assert
+ Assert.Equal("GET", attr.Method);
+ }
+
+ [Fact]
+ public void StellaEndpointAttribute_DefaultTimeoutIs30Seconds()
+ {
+ // Arrange & Act
+ var attr = new StellaEndpointAttribute("GET", "/api/test");
+
+ // Assert
+ Assert.Equal(30, attr.TimeoutSeconds);
+ }
+
+ [Fact]
+ public void ReflectionDiscovery_FindsEndpointsInCurrentAssembly()
+ {
+ // Arrange
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "test-service",
+ Version = "1.0.0",
+ Region = "eu1"
+ };
+ var discovery = new ReflectionEndpointDiscoveryProvider(
+ options,
+ [Assembly.GetExecutingAssembly()]);
+
+ // Act
+ var endpoints = discovery.DiscoverEndpoints();
+
+ // Assert
+ Assert.True(endpoints.Count >= 2);
+ Assert.Contains(endpoints, e => e.Method == "GET" && e.Path == "/api/test");
+ Assert.Contains(endpoints, e => e.Method == "POST" && e.Path == "/api/create");
+ }
+
+ [Fact]
+ public void ReflectionDiscovery_SetsServiceNameAndVersion()
+ {
+ // Arrange
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "my-service",
+ Version = "2.0.0",
+ Region = "eu1"
+ };
+ var discovery = new ReflectionEndpointDiscoveryProvider(
+ options,
+ [Assembly.GetExecutingAssembly()]);
+
+ // Act
+ var endpoints = discovery.DiscoverEndpoints();
+ var endpoint = endpoints.First(e => e.Path == "/api/test");
+
+ // Assert
+ Assert.Equal("my-service", endpoint.ServiceName);
+ Assert.Equal("2.0.0", endpoint.Version);
+ }
+
+ [Fact]
+ public void ReflectionDiscovery_SetsStreamingAndTimeout()
+ {
+ // Arrange
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "test",
+ Version = "1.0.0",
+ Region = "eu1"
+ };
+ var discovery = new ReflectionEndpointDiscoveryProvider(
+ options,
+ [Assembly.GetExecutingAssembly()]);
+
+ // Act
+ var endpoints = discovery.DiscoverEndpoints();
+ var postEndpoint = endpoints.First(e => e.Path == "/api/create");
+ var getEndpoint = endpoints.First(e => e.Path == "/api/test");
+
+ // Assert
+ Assert.True(postEndpoint.SupportsStreaming);
+ Assert.Equal(TimeSpan.FromSeconds(60), postEndpoint.DefaultTimeout);
+ Assert.False(getEndpoint.SupportsStreaming);
+ Assert.Equal(TimeSpan.FromSeconds(30), getEndpoint.DefaultTimeout);
+ }
+}
diff --git a/tests/StellaOps.Microservice.Tests/StellaMicroserviceOptionsTests.cs b/tests/StellaOps.Microservice.Tests/StellaMicroserviceOptionsTests.cs
index 3bbb44706..6db35cd2a 100644
--- a/tests/StellaOps.Microservice.Tests/StellaMicroserviceOptionsTests.cs
+++ b/tests/StellaOps.Microservice.Tests/StellaMicroserviceOptionsTests.cs
@@ -1,5 +1,5 @@
using StellaOps.Microservice;
-using StellaOps.Router.Common;
+using StellaOps.Router.Common.Enums;
using Xunit;
namespace StellaOps.Microservice.Tests;
@@ -40,4 +40,98 @@ public class StellaMicroserviceOptionsTests
Assert.Equal(5000, config.Port);
Assert.Equal(TransportType.Tcp, config.TransportType);
}
+
+ [Fact]
+ public void Validate_ThrowsIfServiceNameEmpty()
+ {
+ // Arrange
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "",
+ Version = "1.0.0",
+ Region = "eu1"
+ };
+
+ // Act & Assert
+ Assert.Throws(() => options.Validate());
+ }
+
+ [Fact]
+ public void Validate_ThrowsIfVersionInvalid()
+ {
+ // Arrange
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "test",
+ Version = "not-semver",
+ Region = "eu1"
+ };
+
+ // Act & Assert
+ var ex = Assert.Throws(() => options.Validate());
+ Assert.Contains("not valid semver", ex.Message);
+ }
+
+ [Fact]
+ public void Validate_ThrowsIfNoRouters()
+ {
+ // Arrange
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "test",
+ Version = "1.0.0",
+ Region = "eu1"
+ };
+
+ // Act & Assert
+ var ex = Assert.Throws(() => options.Validate());
+ Assert.Contains("router endpoint is required", ex.Message);
+ }
+
+ [Fact]
+ public void Validate_AcceptsValidSemver()
+ {
+ // Arrange
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "test",
+ Version = "1.0.0",
+ Region = "eu1",
+ Routers = [new RouterEndpointConfig { Host = "localhost", Port = 5000 }]
+ };
+
+ // Act & Assert - no exception
+ options.Validate();
+ }
+
+ [Fact]
+ public void Validate_AcceptsSemverWithPrerelease()
+ {
+ // Arrange
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "test",
+ Version = "2.1.0-beta.1",
+ Region = "eu1",
+ Routers = [new RouterEndpointConfig { Host = "localhost", Port = 5000 }]
+ };
+
+ // Act & Assert - no exception
+ options.Validate();
+ }
+
+ [Fact]
+ public void DefaultHeartbeatInterval_Is10Seconds()
+ {
+ // Arrange & Act
+ var options = new StellaMicroserviceOptions
+ {
+ ServiceName = "test",
+ Version = "1.0.0",
+ Region = "eu1"
+ };
+
+ // Assert
+ Assert.Equal(TimeSpan.FromSeconds(10), options.HeartbeatInterval);
+ }
}
diff --git a/tests/StellaOps.Router.Common.Tests/FrameTypeTests.cs b/tests/StellaOps.Router.Common.Tests/FrameTypeTests.cs
index dbcd468b0..02587d6b5 100644
--- a/tests/StellaOps.Router.Common.Tests/FrameTypeTests.cs
+++ b/tests/StellaOps.Router.Common.Tests/FrameTypeTests.cs
@@ -1,4 +1,4 @@
-using StellaOps.Router.Common;
+using StellaOps.Router.Common.Enums;
using Xunit;
namespace StellaOps.Router.Common.Tests;
@@ -19,10 +19,10 @@ public class FrameTypeTests
[Fact]
public void TransportType_HasExpectedValues()
{
- // Verify all expected transport types exist
+ // Verify all expected transport types exist (no HTTP per spec)
Assert.True(Enum.IsDefined(typeof(TransportType), TransportType.InMemory));
Assert.True(Enum.IsDefined(typeof(TransportType), TransportType.Tcp));
- Assert.True(Enum.IsDefined(typeof(TransportType), TransportType.Tls));
+ Assert.True(Enum.IsDefined(typeof(TransportType), TransportType.Certificate));
Assert.True(Enum.IsDefined(typeof(TransportType), TransportType.Udp));
Assert.True(Enum.IsDefined(typeof(TransportType), TransportType.RabbitMq));
}
diff --git a/tests/StellaOps.Router.Transport.InMemory.Tests/CancelFlowTests.cs b/tests/StellaOps.Router.Transport.InMemory.Tests/CancelFlowTests.cs
new file mode 100644
index 000000000..8b2415215
--- /dev/null
+++ b/tests/StellaOps.Router.Transport.InMemory.Tests/CancelFlowTests.cs
@@ -0,0 +1,103 @@
+using Microsoft.Extensions.DependencyInjection;
+using StellaOps.Router.Common.Enums;
+using StellaOps.Router.Common.Models;
+using StellaOps.Router.Transport.InMemory;
+using Xunit;
+
+namespace StellaOps.Router.Transport.InMemory.Tests;
+
+public class CancelFlowTests
+{
+ private readonly InMemoryConnectionRegistry _registry;
+ private readonly InMemoryTransportServer _server;
+ private readonly InMemoryTransportClient _client;
+
+ public CancelFlowTests()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddInMemoryTransport();
+
+ var provider = services.BuildServiceProvider();
+ _registry = provider.GetRequiredService();
+ _server = provider.GetRequiredService();
+ _client = provider.GetRequiredService